import { DrizzleAdapter } from '@nextlyhq/adapter-drizzle'; import { WhereClause, DatabaseCapabilities, TransactionContext, SelectOptions, WhereCondition, SqlParam } from '@nextlyhq/adapter-drizzle/types'; import React from 'react'; import { N as NextlyError, V as ValidationPublicData } from './nextly-error.d-WlStqaV9.d.ts'; import { M as MediaParams, a as MediaListResponse, b as MediaResponse, U as UploadMediaInput$1, c as UpdateMediaInput$1, D as DeleteMediaResponse, d as Media } from './media.d-DtIw8UQM.d.ts'; import { S as ServiceErrorCode } from './error-codes.d-CbwkO1ux.d.ts'; import { I as IStorageAdapter, c as ImageProcessor, S as StoragePlugin, M as MediaStorage } from './storage.d-CEowrt6p.d.ts'; import { Table } from 'drizzle-orm'; import { z } from 'zod'; type Brand = T & { readonly __brand: B; }; type AuthUserId = Brand; interface AuthUser { id: AuthUserId; email: string; name?: string | null; image?: string | null; /** * True when the account holds an admin-set password the user must replace * before a session is issued. Carried from the credential check to the login * handler; not persisted in the JWT. */ mustChangePassword?: boolean; } type MinimalUser$1 = { id: number | string; email: string; emailVerified?: Date | string | null; name: string | null; image: string | null; passwordHash?: string | null; roles?: string[] | null; isActive?: boolean | null; sendWelcomeEmail?: boolean | null; createdAt?: Date | string | null; updatedAt?: Date | string | null; [key: string]: unknown; }; type UserAccount = { id: number | string; userId: number | string; provider: string; providerAccountId: string; type: string; }; /** * RBAC Access Control Types * * Type definitions for the hybrid access control system that merges * code-defined access functions with database role/permission checks. * * These types are used by: * - `RBACAccessControlService` for evaluating access * - `defineCollection({ access })` and `defineSingle({ access })` for code-first config * - Admin panel authorization hooks and guards * * @module shared/types/access * @since 1.0.0 */ /** * Minimal user information available in access control context. * * This is a subset of the full user object, containing only * the fields needed for access control decisions. */ interface MinimalUser { /** Unique user identifier */ id: string; /** User's email address (optional) */ email?: string; } /** * Context passed to code-defined access control functions. * * Contains all information needed to make an authorization decision: * user identity, role memberships, resolved permissions, and the * operation being performed. * * @example * ```typescript * const ctx: AccessControlContext = { * user: { id: 'user-123', email: 'user@example.com' }, * roles: ['editor', 'reviewer'], * permissions: ['users:read', 'posts:create', 'posts:read', 'posts:update'], * operation: 'create', * collection: 'posts', * }; * ``` */ interface AccessControlContext { /** The authenticated user (null for unauthenticated requests) */ user: MinimalUser | null; /** The user's role slugs (resolved from DB, includes inherited roles) */ roles: string[]; /** The user's effective permission slugs in 'resource:action' format */ permissions: string[]; /** The operation being performed */ operation: "create" | "read" | "update" | "delete" | "publish" | "unpublish"; /** The collection or single slug */ collection: string; } /** * Code-defined access control function. * * Returns `true` to allow access, `false` to deny. * Can be synchronous or asynchronous. * * @example * ```typescript * // Synchronous — role check * const editorOnly: AccessControlFunction = ({ roles }) => * roles.includes('admin') || roles.includes('editor'); * * // Asynchronous — external check * const checkExternal: AccessControlFunction = async ({ user }) => { * const response = await fetch(`/api/can-access/${user?.id}`); * return response.ok; * }; * ``` */ type AccessControlFunction = (ctx: AccessControlContext) => boolean | Promise; /** * Access control configuration for a collection. * * Each CRUD operation can be controlled with: * - A **function** for contextual rules (receives full `AccessControlContext`) * - A **boolean** for simple allow/deny * - **Omitted** to fall back to database role/permission checks * * Code-defined access always takes precedence over database permissions. * Super-admin always bypasses all access checks. * * @example * ```typescript * defineCollection({ * slug: 'posts', * access: { * // Only admins and editors can create * create: ({ roles }) => roles.includes('admin') || roles.includes('editor'), * // Any authenticated user can read * read: true, * // Only the user's own posts (checked via roles/permissions) * update: ({ roles }) => roles.includes('admin') || roles.includes('editor'), * // Only admins can delete * delete: ({ roles }) => roles.includes('admin'), * }, * fields: [...], * }); * ``` */ interface CollectionAccessControl { /** Access rule for creating new documents */ create?: AccessControlFunction | boolean; /** Access rule for reading/listing documents */ read?: AccessControlFunction | boolean; /** Access rule for updating existing documents */ update?: AccessControlFunction | boolean; /** Access rule for deleting documents */ delete?: AccessControlFunction | boolean; /** * Access rule for moving a document INTO published (publishing). Evaluated on * top of `update`, so a caller must satisfy both to publish. Absent means the * publish permission is resolved from DB grants alone (`publish-`). */ publish?: AccessControlFunction | boolean; /** * Access rule for moving a document OUT of published (unpublishing). Evaluated * on top of `update`. Absent means it is resolved from DB grants alone * (`unpublish-`). */ unpublish?: AccessControlFunction | boolean; } /** * Access control configuration for a single. * * Singles only support read and update operations — they are * auto-created on first access and cannot be deleted. * * @example * ```typescript * defineSingle({ * slug: 'site-settings', * access: { * read: true, * update: ({ roles }) => roles.includes('admin'), * }, * fields: [...], * }); * ``` */ interface SingleAccessControl { /** Access rule for reading the single document */ read?: AccessControlFunction | boolean; /** Access rule for updating the single document */ update?: AccessControlFunction | boolean; /** * Access rule for moving the single INTO published (publishing). Evaluated on * top of `update`. Absent means it is resolved from DB grants alone * (`publish-`). */ publish?: AccessControlFunction | boolean; /** * Access rule for moving the single OUT of published (unpublishing). Evaluated * on top of `update`. Absent means it is resolved from DB grants alone * (`unpublish-`). */ unpublish?: AccessControlFunction | boolean; } /** * Parameters for the `RBACAccessControlService.checkAccess()` method. */ interface CheckAccessParams { /** The authenticated user's ID (null for unauthenticated requests) */ userId: string | null; /** The operation being performed */ operation: "create" | "read" | "update" | "delete" | "publish" | "unpublish"; /** The collection or single slug (used as the permission resource) */ resource: string; /** Optional code-defined access control from defineCollection/defineSingle */ codeAccess?: CollectionAccessControl | SingleAccessControl; /** * Optional transaction-bound Drizzle executor. Supplied when the caller is * already inside a write transaction so the role/permission reads run on that * transaction's own connection rather than taking a second pooled one, which * can stall against a small pool. Defaults to the pooled connection. */ executor?: unknown; } /** * Database Adapter Type * * Provides a type-level description of the database and table shapes * used by BaseService. This enables generic typing of the lazy-cached * `db` and `tables` getters without coupling BaseService to a concrete * dialect (PostgreSQL, MySQL, SQLite). * * The default types use `any` because: * 1. The Drizzle instance type varies by dialect (NodePgDatabase, * MySql2Database, BetterSQLite3Database) * 2. The table schema type varies by dialect * 3. Importing all three would break tree-shaking * 4. 43+ existing child services rely on these being permissive * * Dialect-specific adapters can narrow these types via the generic * parameter: `class MyService extends BaseService<{ db: NodePgDatabase; tables: typeof pgTables }>` * * @module shared/types/database-adapter * @since 1.0.0 */ /** * Shape descriptor for the database layer. * * `db` is the Drizzle instance returned by `adapter.getDrizzle()`. * `tables` is the dialect-specific table schema from `getDialectTables()`. */ interface DatabaseAdapter { db: any; tables: any; } /** * Shared Types * * Common types and interfaces used across all Nextly services. * Services receive dependencies via constructor injection and use these * shared types for consistent request handling and data structures. * * @module shared/types * @since 1.0.0 */ /** * Type alias for Drizzle database instance. * Using `any` because the concrete Drizzle type varies by dialect * (NodePgDatabase, MySql2Database, BetterSQLite3Database). */ type DrizzleDB = any; /** * Request context passed to service methods. * Contains user information, locale, and request metadata. * * @example * ```typescript * const context: RequestContext = { * user: { * id: 'user_abc123', * email: 'user@example.com', * role: 'editor', * permissions: ['posts:read', 'posts:write'], * }, * locale: 'en', * requestId: 'req_xyz789', * }; * * await collectionService.create('posts', input, context); * ``` */ interface RequestContext$2 { /** * Authenticated user information. * Undefined for unauthenticated requests. */ user?: { /** User ID (UUID or CUID format) */ id: string; /** User email address */ email: string; /** User's primary role */ role: string; /** Permission codes the user has (e.g., 'posts:read', 'posts:write') */ permissions: string[]; }; /** Request locale for i18n (e.g., 'en', 'es', 'fr') */ locale?: string; /** Fallback locale (or `false` to disable fallback) for localized reads. */ fallbackLocale?: string | false; /** Unique request identifier for tracing/logging */ requestId?: string; /** * @experimental Bypass the access check for this operation (D35 system * elevation). Validation/hooks/events still run — only the access check is * skipped. Default: undefined (enforce access). */ overrideAccess?: boolean; } /** * System context for internal/CLI operations. * Use this when performing operations that don't have a user context, * such as migrations, seeders, or CLI commands. * * @example * ```typescript * // In a migration or seeder * await userService.create(adminUserData, SYSTEM_CONTEXT); * ``` */ declare const SYSTEM_CONTEXT: RequestContext$2; /** * Pagination options for list queries. * * @example * ```typescript * const options: PaginationOptions = { * limit: 20, * offset: 40, // Skip first 40 records (page 3) * }; * // Or using page-based pagination * const pageOptions: PaginationOptions = { * limit: 20, * page: 3, // Will be converted to offset: 40 * }; * ``` */ interface PaginationOptions { /** Maximum number of records to return (default varies by service) */ limit?: number; /** Number of records to skip */ offset?: number; /** Page number (1-indexed, alternative to offset) */ page?: number; } /** * Paginated result wrapper for list operations. * * @template T - The type of items in the data array * * @example * ```typescript * const result: PaginatedResult = { * data: [user1, user2, user3], * pagination: { * total: 100, * limit: 10, * offset: 0, * hasMore: true, * }, * }; * ``` */ interface PaginatedResult { /** Array of items for the current page */ data: T[]; /** Pagination metadata */ pagination: { /** Total number of records matching the query */ total: number; /** Number of records per page */ limit: number; /** Number of records skipped */ offset: number; /** Whether there are more records after this page */ hasMore: boolean; }; } /** * Sort options for list queries. * * @example * ```typescript * const sort: SortOptions = { * field: 'createdAt', * direction: 'desc', * }; * ``` */ interface SortOptions { /** Field name to sort by */ field: string; /** Sort direction */ direction: "asc" | "desc"; } /** * Common query options combining pagination, sorting, and filtering. * * @example * ```typescript * const options: QueryOptions = { * pagination: { limit: 20, page: 1 }, * sort: { field: 'createdAt', direction: 'desc' }, * where: { status: 'published' }, * }; * * const results = await collectionService.findMany('posts', options, context); * ``` */ interface QueryOptions { /** Pagination settings */ pagination?: PaginationOptions; /** Sort settings */ sort?: SortOptions; /** Filter conditions (key-value pairs) */ where?: Record; /** * Relation population depth (0–5). Omit to keep the service default depth. * @experimental Plugin data-access option (D56). */ depth?: number; /** * Field projection — return only the listed fields (`{ title: true }`). * @experimental Plugin data-access option (D56). */ select?: Record; } /** * Service dependencies interface. * Services receive dependencies via constructor injection. * * @example * ```typescript * class MyService { * constructor(private deps: ServiceDeps) {} * * async doSomething() { * const result = await this.deps.db.select()... * this.deps.logger?.info('Operation completed'); * } * } * ``` */ interface ServiceDeps { /** Drizzle database instance */ db: DrizzleDB; /** Optional logger instance */ logger?: Logger; } /** * Logger interface for service logging. * Compatible with common logging libraries (winston, pino, etc.) * * @example * ```typescript * const logger: Logger = { * debug: (msg, meta) => console.debug(`[DEBUG] ${msg}`, meta), * info: (msg, meta) => console.info(`[INFO] ${msg}`, meta), * warn: (msg, meta) => console.warn(`[WARN] ${msg}`, meta), * error: (msg, meta) => console.error(`[ERROR] ${msg}`, meta), * }; * ``` */ interface Logger { /** Log debug-level message */ debug(message: string, meta?: Record): void; /** Log info-level message */ info(message: string, meta?: Record): void; /** Log warning-level message */ warn(message: string, meta?: Record): void; /** Log error-level message */ error(message: string, meta?: Record): void; } /** * Default console logger implementation. * Provides a simple logger that outputs to console. * Use this as a fallback when no custom logger is configured. * * @example * ```typescript * import { consoleLogger } from 'nextly'; * * const service = new MyService({ * db, * logger: consoleLogger, // or your custom logger * }); * ``` */ declare const consoleLogger: Logger; /** * Base class for all Nextly services providing adapter-based database access. * * This class encapsulates the database adapter pattern, enabling services to work * seamlessly across PostgreSQL, MySQL, and SQLite without database-specific code. * All services should extend this class to gain consistent database access patterns, * transaction management, and helper utilities. * * ## Key Features * * - **Database Abstraction**: Services use adapter interface, not direct Drizzle * - **Transaction Management**: Built-in transaction wrapper with proper typing * - **Query Helpers**: Convenient methods for building WHERE clauses * - **Capability Detection**: Check database-specific feature support * - **Logging**: Integrated logger for all database operations * * ## Architecture * * Services depend on the `DrizzleAdapter` interface, which automatically selects * the correct database adapter (PostgreSQL, MySQL, or SQLite) based on environment * configuration. This enables: * * 1. **Multi-database support** - Same service code works across all databases * 2. **Tree-shaking** - Only the used adapter is bundled * 3. **Type safety** - Full TypeScript support with no `any` casts * 4. **Testability** - Easy to mock adapter for unit tests * * ## Usage Example * * ```typescript * import { BaseService } from './base-service'; * import type { DrizzleAdapter } from '@nextlyhq/adapter-drizzle'; * import type { Logger } from './shared'; * * export class UserService extends BaseService { * constructor(adapter: DrizzleAdapter, logger: Logger) { * super(adapter, logger); * } * * async findUserById(id: string): Promise { * // Access dialect for conditional logic * if (this.dialect === 'postgresql') { * // PostgreSQL-specific optimization * } * * // Use adapter for queries * const user = await this.adapter.selectOne('users', { * where: this.whereEq('id', id), * }); * * if (!user) { * throw new Error('User not found'); * } * * return user; * } * * async updateUser(id: string, data: Partial): Promise { * // Use transaction wrapper * return this.withTransaction(async (tx) => { * const [updated] = await tx.update( * 'users', * data, * this.whereEq('id', id), * { returning: '*' } * ); * return updated; * }); * } * * async searchUsers(email: string): Promise { * // Check capability before using ILIKE * if (this.supportsFeature('supportsIlike')) { * return this.adapter.select('users', { * where: { and: [{ column: 'email', op: 'ILIKE', value: `%${email}%` }] }, * }); * } else { * // Fallback for MySQL/SQLite (uses LOWER() LIKE) * return this.adapter.select('users', { * where: { and: [{ column: 'email', op: 'LIKE', value: `%${email.toLowerCase()}%` }] }, * }); * } * } * } * ``` * * ## Migration from Legacy BaseService * * If migrating from the old BaseService that accepted `db` and `tables`: * * **Before:** * ```typescript * class UserService extends BaseService { * constructor(db: DatabaseInstance, tables: Tables) { * super(db, tables); * } * * async findById(id: string): Promise { * const [user] = await this.db * .select() * .from(this.tables.users) * .where(eq(this.tables.users.id, id)) * .limit(1); * return user; * } * } * ``` * * **After:** * ```typescript * import { users } from '../database/schema'; // Import schema separately * * class UserService extends BaseService { * constructor(adapter: DrizzleAdapter, logger: Logger) { * super(adapter, logger); * } * * async findById(id: string): Promise { * const user = await this.adapter.selectOne('users', { * where: this.whereEq('id', id), * }); * return user; * } * } * ``` * * @see {@link DrizzleAdapter} - Core adapter interface * @see {@link TransactionContext} - Transaction context methods * @see {@link WhereClause} - WHERE clause structure * @see {@link DatabaseCapabilities} - Database feature flags */ declare abstract class BaseService { protected readonly adapter: DrizzleAdapter; protected readonly logger: Logger; private _tables; constructor(adapter: DrizzleAdapter, logger: Logger); /** * Raw Drizzle instance for relational queries (.query.TABLE.findFirst(), etc.). * Prefer this.adapter methods for simple CRUD; use this.db only when you need * Drizzle's query builder directly (JOINs, relational queries, aggregations). * * The v1 relational query API (db.query.users.findFirst with object * filters) is powered by a relations config (defineRelations output), * not a table map. Resolution goes through resolveRelations on EVERY * access — never cached here — so a SchemaRegistry invalidation (table * re-registered by a Builder save) propagates immediately instead of * stranding this service on relations that close over dropped table * objects. The adapter memoizes the drizzle instance per relations * object, so an unchanged schema costs two map lookups. */ protected get db(): TAdapter["db"]; /** * Dialect-specific table schemas resolved from the current adapter. * Cached after first access. */ protected get tables(): TAdapter["tables"]; /** * Get the current database dialect. * * Use this property for conditional logic when you need database-specific behavior. * However, prefer using the adapter's built-in dialect handling when possible. * * @returns The database dialect: 'postgresql', 'mysql', or 'sqlite' * * @example * ```typescript * // Check dialect for conditional logic * if (this.dialect === 'postgresql') { * // Use PostgreSQL-specific optimization * await this.adapter.execute('SELECT ... FOR UPDATE SKIP LOCKED'); * } else { * // Fallback for other databases * await this.adapter.select('users', { where: ... }); * } * ``` * * @example * ```typescript * // Log dialect for debugging * this.logger.info(`Running query on ${this.dialect} database`); * ``` */ protected get dialect(): "postgresql" | "mysql" | "sqlite"; /** * Execute work within a database transaction using Drizzle ORM's fluent API. * * Transactions ensure ACID properties (Atomicity, Consistency, Isolation, Durability) * across multiple database operations. If the callback throws, all changes are rolled * back. If it returns, the transaction commits. * * The `tx` argument is a Drizzle instance that exposes the fluent query API * (`tx.insert(table).values(data)`, `tx.update(table).set(data).where(cond)`, etc.). * On PostgreSQL and MySQL this is a dialect-specific Drizzle transaction object * (`NodePgTransaction` / `MySql2Transaction`). On SQLite it is the shared `this.db` * instance, because better-sqlite3's native transaction API cannot run async callbacks * — see the SQLite branch below for why. * * ## Transaction Behavior by Dialect * * - **PostgreSQL** — routes through Drizzle's native `db.transaction(fn)`. Supports * savepoints, isolation levels, and fully async callbacks. tx is a real * `NodePgTransaction`. * - **MySQL** — same as PostgreSQL via `MySql2Transaction`. * - **SQLite** — better-sqlite3's `db.transaction()` rejects any callback that returns * a promise (`TypeError: Transaction function cannot return a promise`). Since * every Nextly service method is async, we cannot use Drizzle's native SQLite * transaction. Instead we open the transaction manually via `BEGIN IMMEDIATE` * on the shared connection, run the callback against `this.db`, and COMMIT or * ROLLBACK on success/failure. All Drizzle queries against `this.db` during the * callback window execute on the same synchronous connection and therefore * participate in the BEGIN/COMMIT boundary. * * ## Why not the adapter's positional `TransactionContext` * * The adapter's `TransactionContext` (`tx.insert(table: string, data: object)`) * builds raw SQL strings internally. Drizzle's fluent API uses the same * parameterized query builder as the rest of the codebase, gives schema-based * type safety, and is the pattern (db-adapters refactor) standardized on. * The positional adapter context is only kept for legacy collection-service code * paths that have not yet been migrated. * * @param work - Async function executed inside the transaction. Receives a * Drizzle instance (transaction on PG/MySQL, shared db on SQLite) as `tx`. * @returns Promise resolving to the function's return value. * * @throws {DatabaseError} If the transaction fails or is rolled back. * * @example Basic insert + insert atomic * ```typescript * async createUserWithProfile(userData: NewUser, profileData: NewProfile): Promise { * return this.withTransaction(async (tx: any) => { * const [user] = await tx.insert(this.tables.users).values(userData).returning(); * await tx.insert(this.tables.profiles).values({ ...profileData, userId: user.id }); * return user; * }); * } * ``` * * @example Update with rollback on validation failure * ```typescript * async transferCredits(fromId: string, toId: string, amount: number): Promise { * await this.withTransaction(async (tx: any) => { * await tx.update(this.tables.users) * .set({ credits: sql`${this.tables.users.credits} - ${amount}` }) * .where(eq(this.tables.users.id, fromId)); * * await tx.update(this.tables.users) * .set({ credits: sql`${this.tables.users.credits} + ${amount}` }) * .where(eq(this.tables.users.id, toId)); * * const [sender] = await tx.select().from(this.tables.users) * .where(eq(this.tables.users.id, fromId)); * * if (sender.credits < 0) { * // Throwing rolls back BOTH updates atomically. * throw new Error('Insufficient credits'); * } * }); * } * ``` */ protected withTransaction(work: (tx: unknown) => Promise): Promise; /** * Build a simple WHERE clause for equality comparison. * * This is a convenience method for the most common WHERE clause pattern. * For more complex queries, use `whereAnd()` or build the clause manually. * * @param column - Column name to filter * @param value - Value to match (string, number, boolean, Date, null, or undefined) * @returns WHERE clause object * * @example Basic equality * ```typescript * const user = await this.adapter.selectOne('users', { * where: this.whereEq('email', 'user@example.com'), * }); * ``` * * @example With null value * ```typescript * const unverifiedUsers = await this.adapter.select('users', { * where: this.whereEq('emailVerifiedAt', null), * }); * ``` * * @example In update operation * ```typescript * await this.adapter.update( * 'users', * { status: 'active' }, * this.whereEq('id', userId), * { returning: '*' } * ); * ``` * * @example In delete operation * ```typescript * await this.adapter.delete('sessions', this.whereEq('userId', userId)); * ``` */ protected whereEq(column: string, value: unknown): WhereClause; /** * Build a WHERE clause with multiple AND conditions. * * All conditions must be true for a row to match. This is equivalent to * SQL: `WHERE column1 = value1 AND column2 = value2 AND ...` * * For single equality checks, prefer `whereEq()` for simplicity. * For OR conditions, build the clause manually using the WhereClause structure. * * @param conditions - Object mapping column names to their values * @returns WHERE clause object with AND conditions * * @example Multiple filters * ```typescript * const activeAdmins = await this.adapter.select('users', { * where: this.whereAnd({ * role: 'admin', * status: 'active', * emailVerified: true, * }), * }); * ``` * * @example With null values * ```typescript * const pendingUsers = await this.adapter.select('users', { * where: this.whereAnd({ * status: 'pending', * emailVerifiedAt: null, * }), * }); * ``` * * @example Combined with other options * ```typescript * const results = await this.adapter.select('documents', { * where: this.whereAnd({ * collectionSlug: 'posts', * status: 'published', * }), * orderBy: [{ column: 'createdAt', direction: 'desc' }], * limit: 10, * }); * ``` * * @example In transaction * ```typescript * await this.withTransaction(async (tx) => { * const drafts = await tx.select('documents', { * where: this.whereAnd({ * userId: currentUserId, * status: 'draft', * }), * }); * * // Process drafts... * }); * ``` * * @example For complex OR conditions, build manually * ```typescript * // For: WHERE (role = 'admin' AND status = 'active') OR (role = 'superadmin') * const complexWhere: WhereClause = { * or: [ * this.whereAnd({ role: 'admin', status: 'active' }), * this.whereEq('role', 'superadmin'), * ], * }; * const users = await this.adapter.select('users', { where: complexWhere }); * ``` */ protected whereAnd(conditions: Record): WhereClause; /** * Check if the current database supports a specific feature. * * Use this method to write database-agnostic code that gracefully handles * database-specific features. The adapter will automatically provide fallbacks * for unsupported features when possible. * * ## Database Capabilities * * | Feature | PostgreSQL | MySQL | SQLite | * |----------------------|------------|-------|--------| * | supportsJsonb | ✅ | ❌ | ❌ | * | supportsJson | ✅ | ✅ | ✅ | * | supportsArrays | ✅ | ❌ | ❌ | * | supportsIlike | ✅ | ❌ | ❌ | * | supportsReturning | ✅ | ❌ | ✅ | * | supportsSavepoints | ✅ | ❌ | ✅ | * | supportsOnConflict | ✅ | ✅ | ✅ | * | supportsFts | ✅ | ⚠️ | ❌ | * * @param feature - Feature name from DatabaseCapabilities * @returns True if the database supports the feature * * @example Case-insensitive search * ```typescript * async searchByEmail(email: string): Promise { * if (this.supportsFeature('supportsIlike')) { * // PostgreSQL: Use native ILIKE * return this.adapter.select('users', { * where: { and: [{ column: 'email', op: 'ILIKE', value: `%${email}%` }] }, * }); * } else { * // MySQL/SQLite: Adapter handles LOWER() LIKE fallback * return this.adapter.select('users', { * where: { and: [{ column: 'email', op: 'ILIKE', value: `%${email}%` }] }, * }); * // Note: Adapter automatically converts ILIKE to LOWER() LIKE for MySQL/SQLite * } * } * ``` * * @example RETURNING clause support * ```typescript * async updateAndReturn(id: string, data: Partial): Promise { * if (this.supportsFeature('supportsReturning')) { * // PostgreSQL/SQLite: Use RETURNING * const [updated] = await this.adapter.update( * 'users', * data, * this.whereEq('id', id), * { returning: '*' } * ); * return updated; * } else { * // MySQL: Adapter automatically does UPDATE + SELECT * const [updated] = await this.adapter.update( * 'users', * data, * this.whereEq('id', id), * { returning: '*' } * ); * return updated; * // Note: Adapter handles the two-query pattern automatically * } * } * ``` * * @example Savepoint usage * ```typescript * async complexUpdate(): Promise { * await this.withTransaction(async (tx) => { * await tx.insert('audit_log', { action: 'started' }); * * if (this.supportsFeature('supportsSavepoints') && tx.savepoint) { * await tx.savepoint('before_update'); * * try { * await tx.update('sensitive_data', { value: 'new' }, this.whereEq('id', '1')); * } catch (error) { * await tx.rollbackToSavepoint!('before_update'); * this.logger.warn('Update failed, rolled back to savepoint'); * } * } else { * // MySQL: No savepoints, handle differently * await tx.update('sensitive_data', { value: 'new' }, this.whereEq('id', '1')); * } * }); * } * ``` * * @example JSON/JSONB storage * ```typescript * async storeMetadata(id: string, metadata: object): Promise { * if (this.supportsFeature('supportsJsonb')) { * // PostgreSQL: Use JSONB for better performance * this.logger.info('Using JSONB column for metadata'); * } else if (this.supportsFeature('supportsJson')) { * // MySQL/SQLite: Use JSON column * this.logger.info('Using JSON column for metadata'); * } * * await this.adapter.update( * 'documents', * { metadata: JSON.stringify(metadata) }, * this.whereEq('id', id) * ); * } * ``` * * @example All capabilities * ```typescript * logDatabaseCapabilities(): void { * const caps = this.adapter.getCapabilities(); * this.logger.info('Database capabilities', { * dialect: caps.dialect, * jsonb: caps.supportsJsonb, * arrays: caps.supportsArrays, * ilike: caps.supportsIlike, * returning: caps.supportsReturning, * savepoints: caps.supportsSavepoints, * fts: caps.supportsFts, * }); * } * ``` */ protected supportsFeature(feature: keyof DatabaseCapabilities): boolean; /** * Format a Date for database insertion. * * MySQL requires datetime in 'YYYY-MM-DD HH:MM:SS' format, while PostgreSQL * and SQLite accept ISO 8601 format ('YYYY-MM-DDTHH:MM:SS.sssZ'). * * @param date - Date to format (defaults to current date/time) * @returns Formatted date string appropriate for the current database dialect * * @example * ```typescript * const now = this.formatDateForDb(); * await this.adapter.insert('records', { created_at: now }); * ``` */ protected formatDateForDb(date?: Date): Date; /** * Normalize a value from the database into a standard ISO 8601 UTC string. * * Crucial for dynamic tables (Singles) where the DB driver might parse * naive datetime strings using the server's local timezone. * * @param value - The value from the database (Date, string, or unknown) * @returns Optimized ISO string with explicit UTC 'Z' offset */ protected normalizeDbTimestamp(value: unknown): string | null; } /** * Email domain — delivery-log retention policy resolution. * * Recording is unconditional: every send appends one row per recipient, whether * or not anyone will ever read them. Without a retention policy that table grows * for the life of the install, which the schema says in as many words — the * `retention_class` column and the `(retention_class, created_at)` index were * added for this pass and have been inert since. * * It also bounds two things erasure provably cannot reach: rows whose digest was * written under a previous `NEXTLY_SECRET`, and rows written by an older writer * that hashed a display-name address. Neither can be recomputed from an address, * so ageing them out is the only mechanism that removes them at all. * * Resolution is pure and total — it never throws, and it clamps rather than * rejects, so a malformed value degrades to something safe instead of failing a * boot. Mirrors `domains/audit/retention-config.ts` and * `domains/webhooks/retention-config.ts`. * * @module domains/email/retention-config */ /** * A retention window, or `false` to keep rows indefinitely. * * `false` is a position an operator can hold deliberately — a delivery log is * evidence that a message was sent, and some installs need that for longer than * any default should decide. */ type EmailMaxAge = number | false; /** What an install may configure. */ interface EmailRetentionConfig { /** How long a delivery row is kept. `false` keeps it forever. */ maxAgeMs?: EmailMaxAge; /** Shortest time between two passes. */ intervalMs?: number; /** Batches deleted per run, so one pass cannot monopolise a write path. */ maxBatchesPerRun?: number; } interface ResolvedEmailRetentionConfig { maxAgeMs: EmailMaxAge; intervalMs: number; maxBatchesPerRun: number; } /** * SMTP provider configuration. * * Uses nodemailer under the hood for SMTP transport. * * @example * ```typescript * const smtp: SmtpConfig = { * provider: 'smtp', * host: 'smtp.gmail.com', * port: 587, * secure: false, * auth: { user: 'user@gmail.com', pass: 'app-password' }, * }; * ``` */ interface SmtpConfig { provider: "smtp"; /** SMTP server hostname. */ host: string; /** SMTP server port. */ port: number; /** * Use TLS/SSL for the connection. * @default false */ secure?: boolean; /** SMTP authentication credentials. */ auth: { user: string; pass: string; }; } /** * Resend provider configuration. * * @example * ```typescript * const resend: ResendConfig = { * provider: 'resend', * apiKey: process.env.RESEND_API_KEY!, * }; * ``` */ interface ResendConfig { provider: "resend"; /** Resend API key. */ apiKey: string; } /** * SendLayer provider configuration. * * @example * ```typescript * const sendLayer: SendLayerConfig = { * provider: 'sendlayer', * apiKey: process.env.SENDLAYER_API_KEY!, * }; * ``` */ interface SendLayerConfig { provider: "sendlayer"; /** SendLayer API key (Bearer token). */ apiKey: string; } /** * Email template override function. * * Allows overriding the default email templates for auth flows * (welcome, password reset, email verification) in `defineConfig()`. * * The optional `attachments` field in the return value lets a * code-first template declare default attachments sourced from the * media library. At send time they're merged with per-send attachments * (per-send wins on mediaId conflict), then validated against the same * limits as any other attachment list. * * @example * ```typescript * const passwordResetTemplate: EmailTemplateFn = (data) => ({ * subject: `Reset your password`, * html: `

Hi ${data.user.name}, click here to reset.

`, * }); * * const welcomeWithBrochure: EmailTemplateFn = (data) => ({ * subject: `Welcome to Acme`, * html: `

Welcome, ${data.user.name}!

`, * attachments: [{ mediaId: "med_onboarding_pdf" }], * }); * ``` */ type EmailTemplateFn = (data: { /** The user receiving the email. */ user: { name: string | null; email: string; }; /** Auth token (for password reset, email verification). */ token?: string; /** Full URL with token (e.g., password reset link). */ url?: string; }) => { subject: string; html: string; /** Default attachments for this template. Optional. */ attachments?: EmailAttachmentInput[]; }; /** * Configuration for a provider registered by a plugin. * * `provider` names a registered type; everything else is that provider's own * configuration, which its `parseConfig` validates. Deliberately open — core * cannot know the shape of a provider it was never compiled against. * * `custom: true` is a required discriminant, not decoration. Without it this * branch is structurally `{ provider: string, ...anything }`, which also * matches a MALFORMED built-in: `{ provider: "smtp" }` with no host, port or * auth would satisfy the union and defer to a runtime failure an error the * compiler used to catch. The literal keeps the built-in shapes fully checked * while still admitting a provider core has never seen. * * @example * ```ts * email: { * providerConfig: { * custom: true, * provider: "postmark", * serverToken: process.env.POSTMARK_TOKEN!, * }, * from: "Acme ", * } * ``` */ interface RegisteredProviderConfig { custom: true; provider: string; [key: string]: unknown; } /** * Email configuration for `defineConfig()`. * * Provides a code-first fallback for email sending. Database-managed * providers (configured via admin Settings UI) take precedence when * available. * * @example * ```typescript * export default defineConfig({ * email: { * providerConfig: { * provider: 'smtp', * host: 'smtp.gmail.com', * port: 587, * auth: { user: 'user@gmail.com', pass: 'app-password' }, * }, * from: 'My App ', * baseUrl: 'https://example.com', * }, * }); * ``` */ /** * The code-first provider, and the address it sends from. * * Declared as a pair because the resolver treats them as one: `from` is read * ONLY inside the branch that builds an adapter from `providerConfig`, and a * database-managed provider carries its own `fromEmail`. So the two states that * exist are "a code-first provider, with its address" and "no code-first * provider at all" — and an install that manages providers in the admin UI is * squarely the second. * * Previously both were required, which made that second state unrepresentable: * such an install could not write `defineConfig({ email: { retention } })` to * bound or disable its delivery log without inventing a provider it never uses. * A configuration block whose only valid spelling includes a fiction is a * defect in the type, not a discipline for the user. * * A union rather than two optional fields, so `providerConfig` without `from` * still fails: that pair really is required together, and the resolver would * otherwise send from `undefined`. */ type EmailProviderBlock = { /** * Provider configuration. This is the code-first fallback — * database-managed providers take precedence when configured via the * admin UI. * * The three built-in shapes are named so they keep full checking and * autocomplete. `RegisteredProviderConfig` admits any type a plugin * registered: the resolver builds every provider through the registry, so * restricting this to the built-ins would have made a contributed * provider usable from the database and rejected by the compiler in * `defineConfig` — the same provider working or not depending on where it * was configured. */ providerConfig: SmtpConfig | ResendConfig | SendLayerConfig | RegisteredProviderConfig; /** * Default "from" address for all emails. * @example 'Nextly ' */ from: string; } | { providerConfig?: undefined; from?: undefined; }; type EmailConfig = EmailProviderBlock & EmailSettings; /** Everything in `email` that does not depend on where the provider comes from. */ interface EmailSettings { /** * How long the delivery log keeps its rows. * * The log records who was written to, identified by a digest of their * address, so it is a record of people rather than of traffic — and it grows * on every send. Omitting this keeps the default window rather than keeping * rows forever, because an unbounded record of recipients is not a reasonable * default for a table an install fills without opting in. * * `false` keeps everything, at a single window or for the whole block, and is * stated rather than implied by absence. */ retention?: EmailRetentionConfig | false; /** * Application name injected into templates and the shared layout as the * `{{appName}}` variable. Falls back to "Nextly" when not set. * @example 'Acme' */ appName?: string; /** * Base URL for links in emails (e.g., password reset link). * Falls back to `NEXT_PUBLIC_APP_URL` environment variable if not set. * @example 'https://example.com' */ baseUrl?: string; /** * Path for the password reset page link in emails. * The full URL is constructed as `{baseUrl}{resetPasswordPath}?token=...`. * * @default '/admin/reset-password' * @example '/auth/reset-password' */ resetPasswordPath?: string; /** * Path for the email verification page link in emails. * The full URL is constructed as `{baseUrl}{verifyEmailPath}?token=...`. * * @default '/admin/verify-email' * @example '/auth/verify-email' */ verifyEmailPath?: string; /** * Custom email template overrides. * Override the default HTML templates for auth-related emails. */ templates?: { /** Welcome email sent after user registration. */ welcome?: EmailTemplateFn; /** Password reset email with reset link. */ passwordReset?: EmailTemplateFn; /** Email verification email with verification link. */ emailVerification?: EmailTemplateFn; }; } /** * Caller-facing attachment descriptor. * * Attachments must already exist in the Nextly media library. Uploaded * separately via the media API; the email API only references them by ID. * * @example * ```ts * await nextly.email.send({ * to: "user@example.com", * subject: "Your invoice", * html: "

See attached.

", * attachments: [{ mediaId: "med_abc123" }], * }); * ``` */ interface EmailAttachmentInput { /** Media record ID. Required. */ mediaId: string; /** * Override the media's original filename in the outgoing email. * Useful for sanitising or humanising filenames at send-time. */ filename?: string; } /** * Internal attachment shape after resolution (bytes in memory). * * Produced by the attachment resolver; consumed by provider adapters. * Not part of the public API — adapters translate to their provider's * wire format (nodemailer native, base64 for Resend/SendLayer). * * @internal */ interface ResolvedAttachment { filename: string; mimeType: string; content: Buffer; } /** * Provider adapter interface for sending emails. * * Each email provider (SMTP, Resend, SendLayer) implements this * interface. The `EmailService` resolves the active provider and * delegates to its `send()` method. */ interface EmailProviderAdapter { /** * Send an email through this provider. * * @param options - Email sending options * @returns Result with success status and optional message ID */ send(options: { /** Recipient email address. */ to: string; /** Sender email address (e.g., 'App '). */ from: string; /** Email subject line. */ subject: string; /** HTML email body. */ html: string; /** * Plain-text alternative body, sent alongside the HTML as a * `multipart/alternative` message. Optional so custom adapters that * predate this field keep compiling; built-in adapters forward it. */ text?: string; /** * Reply-To address. Optional so custom adapters that predate this * field keep compiling; built-in adapters forward it when set. */ replyTo?: string; /** CC email addresses. */ cc?: string[]; /** BCC email addresses. */ bcc?: string[]; /** * Attachments to include. Each entry is already resolved to raw * bytes — adapters forward to their provider's format. */ attachments?: ResolvedAttachment[]; }): Promise<{ success: boolean; messageId?: string; /** * Addresses the provider refused, when it says so per recipient. * * SMTP answers `RCPT TO` one address at a time, so a server can accept the * message for some recipients and reject it for others while the send as a * whole succeeds. Without this the delivery log records every recipient as * `sent`, and a lookup would claim someone received a message that never * went to them — the one question that table exists to answer. * * Optional, because most API providers report a single outcome for the * message and have nothing per-recipient to say. Absent means "no * per-recipient detail", not "none rejected". * * Each entry must be a BARE MAILBOX — `user@example.com`, never * `Name `. The consumer matches these against the * message's own recipients exactly, after trimming and lowercasing, so a * display-name form matches nothing and every recipient is recorded as * delivered. That failure is silent, which is why the shape is stated here * rather than left to the reader: this interface is what an adapter author * writes against. */ rejected?: string[]; }>; } /** * Attachment Resolver * * Converts caller-facing `EmailAttachmentInput[]` into `ResolvedAttachment[]` * (bytes in memory, ready to forward to a provider adapter). * * Validation runs in order, fails fast — failures throw `NextlyError`: * 1. Count ≤ `limits.maxCount` (else `VALIDATION_ERROR` w/ * `errors[0].code = EMAIL_ATTACHMENT_COUNT_EXCEEDED`) * 2. Each `mediaId` resolves to a record (else `VALIDATION_ERROR` w/ * `errors[0].code = EMAIL_ATTACHMENT_MEDIA_NOT_FOUND`) * 3. Each file reads cleanly from storage (else `INTERNAL_ERROR` w/ * `logContext.emailAttachmentCode = EMAIL_ATTACHMENT_STORAGE_READ_FAILED`) * 4. Total bytes ≤ `limits.maxTotalBytes` (else `VALIDATION_ERROR` w/ * `errors[0].code = EMAIL_ATTACHMENT_SIZE_EXCEEDED`) * * Injected `findMedia` and `readBytes` keep the resolver agnostic of * the concrete `MediaService` / `IStorageAdapter` shapes — easy to mock * in tests and easy to swap if either dependency is refactored. * * @module domains/email/services/attachment-resolver */ /** * Minimal media record needed to resolve an attachment. Subset of * `MediaFile` so the resolver doesn't need the full type surface. */ interface AttachmentMediaRecord { /** Storage path/key — what `readBytes` expects. */ filename: string; /** User-facing filename; used when the input doesn't override. */ originalFilename: string; /** MIME type forwarded to the provider. */ mimeType: string; } /** * What a delivery log row is allowed to know about a message. * * The table stores a hash of the recipient rather than the address, so that it * answers "did this send" and "how many failed" without answering "to whom". * That decision is only worth anything if EVERY value written beside the hash * respects it, and the one that does not respect it by default is the error * string: a mail server quotes the recipient back at you when it rejects them. * * @module domains/email/delivery-record */ /** How a delivery ended. A drain would add `pending` and `retrying`. */ type EmailDeliveryStatus = "sent" | "failed"; /** * How a recipient received the message. * * A copied recipient received it as much as the primary one did, and the table * exists to answer "did this person receive it" — so each gets a row, and this * says which line of the envelope they were on. */ type EmailDeliveryRecipientKind = "to" | "cc" | "bcc"; /** One delivery, as the recorder is told about it. */ interface EmailDeliveryInput { /** The address the message went to. Hashed here; never stored. */ to: string; /** Which line of the envelope carried that address. Defaults to `to`. */ recipientKind?: EmailDeliveryRecipientKind; /** The provider row that carried it, when a stored provider did. */ providerId?: string | null; /** The registered type, kept even after the provider is gone. */ providerType: string; /** Which template produced it, when one did. Never the rendered subject. */ templateSlug?: string | null; status: EmailDeliveryStatus; /** The provider's own message id, when it returned one. */ messageId?: string | null; /** Why it failed. Redacted and bounded before storage. */ error?: string | null; } /** * The email delivery log's writer and reader. * * Records that a message was attempted and what happened to it: which provider * carried it, which template produced it, whether it was accepted, and a hash * of each recipient. One row per RECIPIENT, so the question "did this person * receive it" has an answer for someone who was copied. * * **This is a log, not a queue.** It is PRUNED — `domains/email/prune.ts` sweeps * it by retention class and age, offered from this service after a send is * recorded, because rows here are created by sends and that is when the table * grows. It is still not DRAINED: nothing retries, and `next_attempt_at` and * `attempts` stay inert. See `schemas/email-deliveries/postgres.ts` for why * that distinction is written into the schema rather than only decided here. * * The two halves cover different populations, and neither is sufficient alone. * The sweep bounds every row by age, whoever it belonged to and whichever * secret hashed it. `erase-recipient.ts` answers a named request on demand, and * only for people a caller can name — many recipients never had an account at * all. * * @module domains/email/services/email-delivery-service */ /** One recorded delivery, as a reader sees it. */ interface EmailDeliveryRecord { id: string; providerId: string | null; providerType: string; templateSlug: string | null; /** * The stored digest, or null once this recipient has been erased. * * Null rather than the raw sentinel so a reader never has to know what the * erased state is spelled as. The column is NOT NULL, so there is no "no * value recorded" case for null to be confused with: it means erased and * nothing else. */ recipientHash: string | null; recipientKind: EmailDeliveryRecipientKind; status: EmailDeliveryStatus; attemptCount: number; error: string | null; messageId: string | null; createdAt: Date; } /** How a caller narrows a listing. */ interface ListDeliveriesOptions$1 { /** Hash of the address to look for; callers pass an address, not a hash. */ recipient?: string; status?: EmailDeliveryStatus; providerId?: string; limit?: number; } declare class EmailDeliveryService extends BaseService { private deliveries; /** * Offered after a send is recorded, when retention is configured. * * The trigger is the send rather than a content write, because rows here are * created by sends: that is when the table grows, and an install that never * sends mail has nothing to sweep. `maybeRun` decides whether a pass is * actually due, so this costs an in-process clock check on an ordinary send. */ private readonly retention?; /** * The live retention policy, or undefined when none was configured. * * A function rather than a value, because a runner built at boot outlives * every hot reload: reading it per call is what lets a saved change take * effect without a restart, and it is the same value the sweep runs on. */ private readonly retentionPolicy?; constructor(adapter: DrizzleAdapter, logger: Logger, retention?: { maybeRun(maxBatches?: number): Promise; }, retentionPolicy?: () => ResolvedEmailRetentionConfig | undefined); /** * Whether this install has asked to keep no delivery history at all. * * A window of zero is not "prune aggressively" — it is an operator saying * they want none of this retained. Writing the row and deleting it later * satisfies neither half of that: the digest is in the table until a pass is * next due, which the gate holds off for a full interval, and the rows * written after the final pass stay indefinitely because nothing offers * another one. Not writing it is the only reading that means what it says. */ private keepsNothing; /** * Record one delivery attempt. * * Never throws. A send that succeeded must not be reported as failed because * the log could not be written, and a send that failed must not have its * failure replaced by a different one. The recording failure goes to the * process log, so a trail that stops being written is visible. * * `next_attempt_at` is not set. Nothing drains this table, and a timestamp * there would tell an operator to expect a retry that no code will perform. */ record(input: EmailDeliveryInput): Promise; /** * Record every recipient of one message. * * A message with copied recipients produces one row per address, because the * question the table answers is asked about a PERSON and a person copied on * a message received it. * * Written in bounded chunks: an ordinary send is one statement, and a large * recipient list becomes several rather than one that would exceed a * dialect's bind-parameter limit. Each chunk succeeds or fails on its own, * so a batch can end up partially recorded -- which is the deliberate trade * against losing all of it. * * Never throws, for the reason `record` gives. */ recordAll(inputs: EmailDeliveryInput[]): Promise; /** * Batches this runner may spend when a SEND offered the pass. * * A write path wants a bounded amount of work, not a backlog sweep. This * runner carries every domain's policy, so an uncapped offer spends each * one's full configured budget — dozens of delete batches by default — while * the caller waits, AFTER the provider has already accepted the message. * Long enough to hit a serverless request timeout, and the caller's natural * response to a timeout is to send the mail again. * * Two, matching the other mutation services. Full-budget sweeps belong to * triggers nothing is waiting on. */ private static readonly WRITE_PATH_PRUNE_BATCHES; /** * One bounded slice of a batch: insert it, and recover the one way that can * be recovered. * * Per chunk rather than per batch, because the provider retry rewrites the * rows it was given. Retrying a whole batch after a later chunk failed would * re-insert the ids an earlier chunk had already committed, and collide on * the primary key. */ private insertChunk; /** * Say that a row was kept without its provider reference, and never let * saying so change what happened. * * The row is already inserted by the time this runs. An installed logger * that throws would otherwise be caught by the recovery's own handler and * reported as a retry that failed -- an error naming a row that exists, on a * path whose whole purpose was to keep it. Isolated for the same reason * `reportInsertFailure` is, and the two sit together so neither is the one * that gets forgotten. */ private reportProviderReferenceDropped; /** * One shape for a lost chunk, so the two report sites cannot diverge. * * The log call is isolated. `recordAll` promises never to throw, and it is * called from a send that has already been dispatched -- so a logger an * install supplied, throwing from `error()`, would otherwise escape a * recorder whose whole contract is that it cannot affect the send, and be * caught as a provider failure by the path above it. A trail that cannot be * written is a trail that cannot be written; it is not a failed message. */ private reportInsertFailure; private logInsertFailure; /** * The insert itself, with or without the provider reference. * * Split out so the retry above writes exactly the same rows rather than a * second, subtly different statement — which is how a fallback path comes to * store something the primary one never would. The ids are passed in for the * same reason: the retry must replace the failed rows, not add new ones. */ private insertRows; /** * Erase every delivery recorded for an address. * * The reachable entry point for the population `deleteUser` cannot serve: * most recipients never had an account — a password reset to an address that * never registered, a CC, a BCC added by a `beforeSend` filter — and no * account deletion will ever fire for them. Without a caller of its own, the * erasure would cover an arbitrary subset of the people it claims to. * * Runs outside a transaction because it stands alone here; `deleteUser` * calls the underlying function directly with its own so the erasure commits * and rolls back with the account removal. */ eraseRecipient(address: string): Promise; /** * List recorded deliveries, newest first. * * `recipient` takes an ADDRESS and hashes it here, because a caller holding a * hash is a caller who has been handed one — and the only supported way to * ask about a person is to already know which person you mean. */ list(options?: ListDeliveriesOptions$1): Promise; } /** * Who performed a request. * * The canonical identity a write path records for attribution. Derived at the * transport boundary (where authentication is resolved) and threaded down to * the mutation services, which would otherwise only see a user id and could not * tell a signed-in person from an API key acting on their behalf. * * Kept transport-neutral and free of webhook types: the webhook envelope is one * consumer, and durable audit logging is the next. * * @module auth/request-actor */ /** What kind of caller performed a write. */ type RequestActorType = "user" | "apiKey" | "system"; /** * The acting identity for one write. * * For `apiKey` the id is the API key's own id rather than the key owner's user * id: the key is the actor, and its owner is an attribute of the key that stays * recoverable from the keys table. `type` already distinguishes the two, so the * single id slot carries the most precise identity available. */ interface RequestActor { type: RequestActorType; id?: string; } /** * Dialect-Agnostic Type Definitions for Email Providers * * These types define the structure for the `email_providers` table * used to manage email sending providers (SMTP, Resend, SendLayer) * via the Admin UI. All dialect-specific schemas (PostgreSQL, MySQL, * SQLite) will implement these interfaces. * * @module schemas/email-providers/types * @since 1.0.0 */ /** * Supported email provider types. * * - `smtp`: SMTP server (uses nodemailer) * - `resend`: Resend API (uses resend SDK) * - `sendlayer`: SendLayer API (uses REST API) * * @example * ```typescript * const type: EmailProviderType = 'resend'; * ``` */ type EmailProviderType = "smtp" | "resend" | "sendlayer" | (string & {}); /** * Insert type for creating a new email provider. * * Contains all required and optional fields for inserting a provider * into the `email_providers` table. Fields with defaults (like * `isDefault`, `isActive`) are optional on insert. * * @example * ```typescript * const newProvider: EmailProviderInsert = { * name: 'Production SMTP', * type: 'smtp', * fromEmail: 'noreply@example.com', * fromName: 'My App', * configuration: { * host: 'smtp.gmail.com', * port: 587, * secure: false, * auth: { user: 'user@gmail.com', pass: 'encrypted...' }, * }, * }; * ``` */ interface EmailProviderInsert { /** Display name for this provider (e.g., "Production SMTP", "Resend API"). */ name: string; /** Provider type determining which adapter is used for sending. */ type: EmailProviderType; /** * Default sender email address. * @example 'noreply@example.com' */ fromEmail: string; /** * Default sender display name. * @example 'My App' */ fromName?: string | null; /** * Provider-specific configuration stored as JSON. * Sensitive fields (passwords, API keys) are encrypted at rest. * * Shape depends on `type`: * - `smtp`: `{ host, port, secure, auth: { user, pass } }` * - `resend`: `{ apiKey }` * - `sendlayer`: `{ apiKey }` */ configuration: Record; /** * Whether this is the default provider for sending emails. * Only one provider can be default at a time. * @default false */ isDefault?: boolean; /** * Whether this provider is currently active. * Inactive providers are stored but not used for sending. * @default true */ isActive?: boolean; } /** * Full record type for an email provider. * * Extends `EmailProviderInsert` with all required fields that are * set by the database (id, timestamps) or have default values. * * @example * ```typescript * const provider: EmailProviderRecord = { * id: 'uuid-123', * name: 'Production Resend', * type: 'resend', * fromEmail: 'noreply@example.com', * fromName: 'My App', * configuration: { apiKey: '••••••••' }, * isDefault: true, * isActive: true, * createdAt: new Date(), * updatedAt: new Date(), * }; * ``` */ interface EmailProviderRecord extends EmailProviderInsert { /** Unique identifier (UUID or CUID). */ id: string; /** Whether this is the default provider (required on record). */ isDefault: boolean; /** Whether this provider is active (required on record). */ isActive: boolean; /** When the provider was created. */ createdAt: Date; /** When the provider was last updated. */ updatedAt: Date; } /** * Email Provider Service * * CRUD operations for managing email providers stored in the * `email_providers` table. Supports SMTP, Resend, and SendLayer * providers with default provider management and test sending. * * Configuration JSON is encrypted at rest using AES-256-GCM. * Public-facing methods return masked configuration; internal * methods provide decrypted access for email sending. * * @module services/email/email-provider-service * @since 1.0.0 */ /** * Input for creating a new email provider. * Extends EmailProviderInsert (all required + optional fields). */ type CreateEmailProviderInput = EmailProviderInsert; /** * Input for updating an existing email provider. * All fields are optional — only provided fields are updated. * Note: `type` may be changed; the admin supports switching provider on * edit. A change re-validates the stored configuration against the new * provider, because the rules that accepted it belonged to the old one. */ interface UpdateEmailProviderInput { name?: string; type?: EmailProviderType; fromEmail?: string; fromName?: string | null; configuration?: Record; /** * Configuration paths this update REMOVES, as declared field names. * * Out of band rather than a marker value inside `configuration`, because a * patch merged over stored configuration otherwise has only two states -- * absent means "leave it", a value means "set it" -- and no way to say * "unset it", so an optional field became permanent the moment it was first * saved. Clearing it in the form omitted it, and omission is * indistinguishable from not touching it. * * Every in-band alternative collides with real data: a provider's * `parseConfig` may legitimately accept `null`, an empty string, or any * sentinel string chosen here, and a create already stores those verbatim. * A separate list cannot be confused with a value because it does not live * in the value space at all. * * Each entry must name a field the effective provider DECLARES. Anything * else is rejected, which also means these strings never become arbitrary * object paths. */ unsetConfiguration?: string[]; isDefault?: boolean; isActive?: boolean; } declare class EmailProviderService extends BaseService { /** * Where a test send is recorded. * * The Test button dispatches a REAL message, so it belongs in the delivery * log for the same reason every other send does: an operator asking "did * anything go out" should not have to know which button produced it. * Optional, so a missing recorder never prevents a send. */ private readonly deliveries?; private emailProviders; private encryptionSecret; constructor(adapter: DrizzleAdapter, logger: Logger, /** * Where a test send is recorded. * * The Test button dispatches a REAL message, so it belongs in the delivery * log for the same reason every other send does: an operator asking "did * anything go out" should not have to know which button produced it. * Optional, so a missing recorder never prevents a send. */ deliveries?: EmailDeliveryService | undefined); /** * Encrypt a configuration JSON object for storage. * * Refuses rather than degrading. A provider's configuration holds SMTP * passwords and API keys, so with no secret to encrypt them under the only * alternative is to write the credential readable to anyone with database * access. `domains/webhooks/secret.ts` already refuses for the same threat; * this keeps the two consistent instead of leaving the higher-value * credential on the weaker policy. * * The message names the variable because the operator is one environment * setting away from working, and a variable name is not itself a secret. */ /** * A provider's configuration, parsed and checked to be storable as it is. * * Parsing happens HERE rather than at each caller, because every property * below is about what may be persisted, and a caller that parsed on its own * would be a write that skipped the checks. * * The parsed value is `unknown` by design — the erased form does not expose * the type — so each property is checked rather than assumed. */ private storableConfiguration; /** * A configuration reduced to its OWN enumerable fields. * * `JSON.stringify` reads own enumerable properties only, so the round trip * is what strips an inherited one -- and doing it BEFORE the parser runs is * what stops a schema from reading the prototype and reporting success. * * A value that cannot be written is reported as the provider-configuration * fault it is, rather than reaching the parser and failing later as * something less specific. */ private ownFieldsOf; private encryptConfiguration; /** * Decrypt a stored configuration value back to a JSON object. * * Deliberately more permissive than its write counterpart: it still accepts a * non-string stored value, which is what an install that wrote configuration * before the write path refused would have. Refusing to read those rows would * turn a credential stored in the clear into a provider nobody can open, * rotate, or delete — hiding exactly the records an operator needs to find. * The public read path masks them like any other, so tightening this would * cost recoverability and buy no confidentiality. */ private decryptConfiguration; /** * Decrypt, and say whether it worked. * * `decryptConfiguration` answers `{}` for an unreadable value, which is the * right thing for a READ -- a provider whose ciphertext no longer decrypts * must still be listable, maskable and deletable rather than becoming a row * nobody can act on. It is the wrong thing for a COMPARISON: `{}` is also * what a genuinely empty configuration looks like, so a diff against an * unreadable preimage concludes nothing changed at the moment it is least * entitled to. Callers about to make a claim take this form and ask. */ private readConfiguration; /** * The dotted paths a provider declared as secret, e.g. `auth.pass`. * * Returns null when the type is not registered — an uninstalled plugin * leaves rows behind, and those must still be readable and still masked. */ private declaredSecretPaths; /** Every path the provider describes, secret or not. */ private declaredConfigPaths; /** * Mask a configuration object for a public read. * * Which values are secret is DECLARED by the provider, not guessed from key * names. The name heuristic below cannot know that `credential` holds one and * that a field merely containing `token` may not, and it can only ever be * right about names core has seen before — which is none of a plugin's. * * When no definition is available — the provider's package was uninstalled, * or it shipped no field metadata — EVERY leaf is masked rather than guessed * at. The key-name heuristic cannot reconstruct what the definition declared, * so a field the provider correctly marked secret (`credential`, say) would * come back in the clear precisely when the plugin that knew better is gone. * Absence of information has to mask more, not less; an over-masked read is * recoverable by reinstalling the package, a leaked credential is not. */ /** * Whether a configuration path is one this read withholds. * * Three states, not two. `null` means no usable definition, so nothing is * known and everything is secret. Otherwise a path is public ONLY if the * provider declared it and did not mark it secret: a key the definition does * not mention at all -- a credential left behind by a plugin upgrade, say -- * is unknown rather than public, and the parsers strip unknown keys for * adapter construction without removing them from storage. * * Asked by the mask AND by the strip that undoes it. Masking one set of * paths and unmasking a smaller one is not a mismatch that shows up as a * failure: a client echoing back what it was given writes the literal mask * over a real stored value, and the update reports success. */ private pathIsSecret; private maskConfiguration; private isPlainObject; /** * Drop the mask a client echoes back for a credential it did not touch. * * Restricted to paths the provider DECLARED secret. The value is the only * signal otherwise, and `••••••••` is a string a non-secret text field may * legitimately hold — dropping it there discards a real edit and reports * success, so the operator sees the old value survive a save they made. * * `secretPaths` is null when no definition is available (an uninstalled * plugin, or a provider that shipped no field metadata). Nothing is stripped * then: with no way to tell a credential from a value, keeping what the * caller sent is the choice that cannot silently lose an edit, and the * provider's own parser still decides whether the result is usable. */ private stripMaskedConfigValues; private deepMergeConfig; /** * Narrow `unsetConfiguration` from what a request actually sent. * * The REST route copies this field out of parsed JSON, so the declared type * is a promise rather than a fact. A non-array reaching the walk below would * fail as a TypeError -- a 500 with a driver-shaped message for a malformed * request -- instead of the validation error the caller can act on. */ private readUnsetPaths; /** * Remove the configuration paths an update asked to unset. * * Each path must name a field the provider DECLARES. That is the whole * safety argument: the strings are checked against a set built from the * registry, and `assertConfigFieldsAreUsable` already refuses a field named * `__proto__`, `constructor` or `prototype` at registration, so a request * cannot steer this walk anywhere a declared field does not go. An * undeclared path is rejected rather than ignored, because silently doing * nothing would leave the operator looking at a value they just cleared. * * Unsetting an absent path is not an error — it is the state being asked * for, and a retried request must not fail on its second attempt. */ private applyConfigUnsets; /** * Read a raw row from the database and return it with masked configuration. */ private toMaskedRecord; /** * Read a raw row from the database and return it with decrypted configuration. */ private toDecryptedRecord; /** * Create a new email provider. * * Configuration is encrypted before storage. * * If `isDefault` is true, the demotion of the previous default and this * insert are one transaction, so the table never holds two defaults and * never holds none. It does not serialise two callers doing this at once. */ createProvider(data: CreateEmailProviderInput, /** * Who performed this, for the audit trail. Optional so an internal or * seeded write needs no ceremony; those produce no entry by design. */ actor?: RequestActor | null): Promise; /** * Refuse to promote a provider nothing can build an adapter for. * * Promotion decides which provider carries every unrouted message, so * promoting a type whose plugin has been removed points all of them at * something that fails at send time -- AND clears the working default on the * way, so the damage outlives the request that caused it. * * Written once because two methods promote: `setDefault`, and * `updateProvider` with `isDefault: true`. The second reaches the same * statement through a catch-all PATCH or a Direct API update that names no * configuration at all, so a guard living in the first is a guard the second * does not have. * * Refused BEFORE anything is written, so a refusal leaves the stored default * exactly as it was and there is nothing to attribute in the trail. */ private assertPromotable; /** * Take the default away from every provider except the one that now holds it. * * Runs BEFORE the write that promotes, inside the same transaction. * Postgres carries a partial unique index over `is_default = true` and * checks it as each statement runs, so a row cannot take the default while * the incumbent still holds it — the incumbent gives it up first, and the * transaction is what makes the gap between them invisible and undoable. * * Its caller checks that the promotion has a target before calling this. A * promoting write that matches nothing after the incumbent has been stripped * would leave the installation unable to send anything it was not given a * provider for, with nothing in the trail to say why. * * Returns the rows it demoted rather than recording them. An entry written * from in here would claim a demotion a rollback then took back, and the * trail's one job is to not say that. * * This settles the ORDER of a handover, not who wins a race for it. Two * concurrent promotions still both commit, on MySQL and SQLite as well as * Postgres, because nothing here locks the rows it read. */ private demoteOtherDefaults; /** * Write the trail entries for a handover, once it has committed. * * Separate from the statement that demoted them for the reason the entries * are worth having: a durable claim that a provider stopped being the * default has to outlive only the transactions that actually did it. */ private recordDemotions; /** * Record a provider mutation, and never let recording break the mutation. * * The write has already committed by the time this runs. `recordProviderActivity` * failing is not a reason to report the write as failed, so the failure is * turned into a log line instead -- a trail that quietly stops being written * should be visible somewhere. */ private recordActivity; /** * Get a single email provider by ID. * Returns masked configuration — use `getProviderDecrypted()` for internal access. * * @throws NextlyError NOT_FOUND if provider doesn't exist */ getProvider(id: string): Promise; /** * List all email providers, ordered by creation date (newest first). * Returns masked configuration for all providers. */ listProviders(): Promise; /** * Update an existing email provider. * Configuration is encrypted before storage. * * Provider `type` may be changed. The stored configuration is * re-validated against the new provider, since the rules that accepted it * belonged to the old one. * * @throws NextlyError NOT_FOUND if provider doesn't exist */ updateProvider(id: string, data: UpdateEmailProviderInput, actor?: RequestActor | null): Promise; /** * Delete an email provider. * * Cannot delete the default provider — set another provider * as default first. * Idempotent — returns successfully if provider doesn't exist. * * @throws NextlyError BUSINESS_RULE_VIOLATION if provider is the default */ deleteProvider(id: string, actor?: RequestActor | null): Promise; /** * Set a provider as the default. * * The demotion of the previous default and this promotion are one * transaction, and the target is checked before either, so a promotion that * matches nothing cannot leave the installation with no default at all. It * does not serialise two callers promoting different providers at once. * * @throws NextlyError NOT_FOUND if provider doesn't exist */ setDefault(id: string, actor?: RequestActor | null): Promise; /** * Get the default email provider with masked configuration. * * Returns `null` if no default is configured. */ getDefaultProvider(): Promise; /** * Test an email provider by sending a test email. * * Validates that the provider exists and is active, then creates a * temporary adapter from the provider's decrypted configuration and * sends a test email directly (avoids circular dependency with EmailService). */ testProvider(id: string, testEmail?: string, /** * `"send"` dispatches a real message, which is what the REST route and the * admin's Send Test button promise. `"connection"` asks the provider's own * probe instead and sends nothing — available only where the descriptor * reports `capabilities.connectionTest`. Defaulted so every existing caller * keeps the contract it was written against. */ mode?: "send" | "connection" | undefined): Promise<{ success: boolean; error?: string; }>; /** * Record a test send, and never let recording change its outcome. * * The message has already gone out (or already failed) by the time this * runs, so a recording failure must not turn a delivered test into a * reported failure. `record` swallows its own errors; this wrapper exists so * the call site reads as one thing. */ private recordTestDelivery; /** * Create a provider adapter from a decrypted provider record. */ private createAdapterFromProvider; /** * Get a single email provider with decrypted configuration. * **Internal use only** — for email sending adapters that need real credentials. * * @throws NextlyError NOT_FOUND if provider doesn't exist */ getProviderDecrypted(id: string): Promise; /** * Get the default email provider with decrypted configuration. * **Internal use only** — for email sending adapters that need real credentials. * * Returns `null` if no default is configured. */ getDefaultProviderDecrypted(): Promise; /** * Fetch a raw provider row from the database (no decryption or masking). * * @throws NextlyError NOT_FOUND if provider doesn't exist */ private getRawProvider; } /** * Canonical response-shape helpers. Every server handler (dispatcher * methods, REST endpoints, auth handlers, routeHandler direct branches) * converges on these instead of hand-rolling JSON. * * Contract lives in the envelope spec, section 5.1. * * Eight op-types: * respondList to { items, meta } (paginated find) * respondDoc to T (bare) (findByID) * respondMutation to { message, item, warnings? } (create/update/delete) * respondAction to { message, warnings?, ...result } (non-CRUD mutation) * respondData to T (bare object) (non-CRUD read) * respondCount to { total } (count) * respondBulk to { message, items, errors, warnings? } (bulk by id) * respondBulkUpload to { message, items, errors, warnings? } (bulk upload) * * Errors do NOT use these helpers. Errors flow through `withErrorHandler` * (REST API) or the routeHandler error path (dispatcher API), both of * which emit the canonical singular `{ error: NextlyErrorJSON }` shape. * See docs section 6. * * Note on bulk vs. error: respondBulk / respondBulkUpload are NOT errors. * A bulk request always succeeds at the request layer (HTTP 200) so long * as the request itself is well-formed; per-item failures are first-class * data in the body's `errors` array. 4xx is reserved for malformed bulk * requests (e.g. empty `ids` array) where the dispatcher's pre-check * throws NextlyError.validation BEFORE entering the service. */ type PaginationMeta = { total: number; page: number; limit: number; totalPages: number; hasNext: boolean; hasPrev: boolean; }; /** * A scoped API key is authorized on ITS OWN stamped grants, not its owner's. * * The route middleware authenticates an API-key request and stamps the key's * scoped permission list on the dispatcher params. A service-side access re-check * (for example the publish/unpublish transition gate, which the route never * authorized because it only saw the write as `update`) must judge the key on * that stamped scope. Resolving the permission from the key OWNER's `userId` * instead — as the ordinary RBAC path does — would let an update-only key issued * by a publisher publish, and deny a publish-scoped key issued by a non-publisher. * This mirrors `auth/entity-read-access.ts` (`canReadEntity`) for the write side. * * @module auth/authenticated-scope */ /** * The authenticated caller's scope, as a service access check needs it. * * `permissions` are the API key's OWN scoped grants in `{action}-{resource}` * form (the same format the route stamps and `canReadEntity` consumes), e.g. * `publish-posts`. Only meaningful when `actorType` is `apiKey`; a session or * system caller carries none here and resolves its grants the normal way. */ interface AuthenticatedScope { actorType: RequestActorType; permissions: string[]; } /** * Database Lifecycle Hooks System - Hook Registry * * Centralized registry for managing and executing database lifecycle hooks. * Implements a singleton pattern with Map-based storage for efficient hook lookups. * * @module hooks/hook-registry * @since 1.0.0 */ /** * The handlers one owner contributes to one collection. * * `beforeOperation` is held apart from the rest because its handlers take the * operation's args rather than a document, and the two signatures are not * interchangeable. */ interface OwnedHookSet { byPhase: Array<{ hookType: HookContextPhase; handlers: HookHandler[]; }>; beforeOperation: BeforeOperationHandler[]; } interface SideEffectHookFailure { /** The phase whose handler threw. */ phase: HookType; /** The collection or single the operation was for. */ collection: string; /** The normalized error, with its type and context preserved. */ error: NextlyError; /** * The row the handler was running for, when it is known. * * A bulk operation runs its items concurrently, so warning order cannot be * matched against the ordered `successes` array. Without the id a caller * knows a side effect failed for one of the rows it just wrote and cannot * tell which, which is not enough to remediate. */ entryId?: string; } /** * Global hook registry singleton * * Manages registration and execution of database lifecycle hooks. * Supports collection-specific hooks and global wildcard hooks. * * **Features:** * - Collection-specific hooks: Register hooks for individual collections * - Global wildcard hooks: Register hooks for all collections using `*` * - Series execution: Hooks run in registration order (FIFO) * - Data transformation: `before*` hooks can modify data * - Side effects: `after*` hooks run for side effects only * - Performance optimization: `hasHooks()` check to skip execution * * **Usage:** * ```typescript * import { getHookRegistry } from 'nextly/hooks'; * * const registry = getHookRegistry(); * * // Register a hook * registry.register('beforeCreate', 'posts', async (context) => { * return { ...context.data, slug: slugify(context.data.title) }; * }); * * // Execute hooks * const modifiedData = await registry.execute('beforeCreate', { * collection: 'posts', * operation: 'create', * data: { title: 'My Post' }, * context: {} * }); * ``` * * @class HookRegistry */ /** * Turn whatever a hook threw into the error the boundary should see. * * A hook that rejects its input does so deliberately, and says how: a * validation error carries field issues, a forbidden one carries a status. * Rebuilding it as a generic error throws all of that away and the boundary * answers 500, so a hook enforcing a rule reports a server fault instead of * the rule. * * Anything else really is unexpected. The original is kept as `cause` rather * than flattened into a message, so its stack survives, and the hook and * collection travel in log context where they are useful without being * disclosed to the caller. */ declare class HookRegistry { /** * Internal storage for hooks * * Key format: `${hookType}:${collection}` * Examples: "beforeCreate:posts", "afterUpdate:users", "beforeCreate:*" * * Wildcard key "*" matches all collections. */ private hooks; /** * `beforeOperation` handlers, kept apart from the rest. * * Every other phase receives a `HookContext` and reshapes `data`; * `beforeOperation` receives a `BeforeOperationContext` and reshapes `args`. * Those are different function types, so storing them together would mean * recovering the real one with a cast on the way out -- and a cast is exactly * what let a handler be declared against the wrong context in the first place. */ private beforeOperationHooks; /** * Owners whose handlers stay registered but do not run. * * Disabling a plugin has to stop everything it contributed, and its * declarations are rebuilt from the config so removing those is safe. Its * `ctx.hooks.on` registrations are not: they were made during `init`, which a * config reload does not re-run, so deleting them would leave re-enabling the * plugin in the same session silently short of its handlers until a restart. * Suspending is what makes the switch work in both directions. */ private suspendedOwners; /** * Replace the set of suspended owners. * * Whole-set rather than incremental so it can be recomputed from the config * on each reload: an owner absent from the new set resumes by construction, * which is what re-enabling a plugin needs, and nothing has to remember what * a previous reload suspended. */ setSuspendedOwners(owners: Iterable): void; /** The currently suspended owners, for a caller that has to restore them. */ getSuspendedOwners(): HookOwner[]; /** Drop the entries whose owner is suspended, cheaply when none is. */ private runnable; /** * How many handlers each key held at the moment the config last registered. * * Recorded rather than inferred. Where the config's handlers belong is decided * by boot -- `registerServices` runs `initializePlugins`, then * `registerCollectionHooks` -- but the entries that precede that point are not * all plugins: an app's `registerHook` runs when its module is evaluated, * which may be before the first `getNextly()` or long after. Two rounds of * deriving the position from the entries present (rank the owners; anchor to * the plugins) each reproduced boot for some arrangements and inverted it for * others, because the ordering is a fact about WHEN something registered and * the entries do not carry it. */ private configBoundaries; /** * Note where the config's handlers start, immediately before boot registers * them. * * Everything already present belongs ahead of the config, whoever owns it, so * a handler the config declares for the first time during a reload lands where * a restart would put it. */ markConfigRegistrationPoint(): void; /** Append to a handler list, creating it on first use. */ private pushHandler; /** Remove one handler by identity, dropping the list once it is empty. */ private removeHandler; /** * Register a hook for a specific collection and hook type * * Hooks are executed in the order they are registered (FIFO). * Multiple hooks can be registered for the same hook type and collection. * * @param hookType - Type of hook (beforeCreate, afterCreate, etc.) * @param collection - Collection name or '*' for global hooks * @param handler - Hook function to execute * * @example * ```typescript * const registry = getHookRegistry(); * * // Collection-specific hook * registry.register('beforeCreate', 'users', async (context) => { * context.data.password = await bcrypt.hash(context.data.password, 10); * return context.data; * }); * * // Global hook (runs for all collections) * registry.register('afterCreate', '*', async (context) => { * console.log(`Created ${context.collection}:`, context.data.id); * }); * ``` */ register(hookType: HookContextPhase, collection: string, handler: HookHandler, owner?: HookOwner): void; /** * Refuse `beforeOperation` on a method that cannot honour it, naming the one * that can. */ private rejectBeforeOperation; /** * Register a `beforeOperation` hook. * * Separate from {@link register} because the handler signature is different: * it is handed the operation's `args` -- the data, id or where clause the * operation is about to use -- and returning a modified set replaces them. * Handlers for every other phase receive `data` instead, and the two are not * interchangeable. * * @param collection - Collection name or '*' for global hooks * @param handler - Hook function to execute */ registerBeforeOperation(collection: string, handler: BeforeOperationHandler, owner?: HookOwner): void; /** * Unregister a specific hook * * Removes the exact handler function from the registry. * Useful for cleanup when hooks are no longer needed. * * @param hookType - Type of hook * @param collection - Collection name or '*' * @param handler - The exact handler function to remove * * @example * ```typescript * const myHook = async (context) => { ... }; * * registry.register('beforeCreate', 'posts', myHook); * // Later... * registry.unregister('beforeCreate', 'posts', myHook); * ``` */ unregister(hookType: HookContextPhase, collection: string, handler: HookHandler, owner?: HookOwner): void; /** * Unregister a specific `beforeOperation` hook, the counterpart to * {@link registerBeforeOperation}. * * @param collection - Collection name or '*' * @param handler - The exact handler function to remove */ unregisterBeforeOperation(collection: string, handler: BeforeOperationHandler, owner?: HookOwner): void; /** * Remove only the handlers a given owner registered for a collection. * * A config reload has to replace the app's own handlers while leaving a * plugin's alone: a plugin can register directly into a collection's * namespace -- the form builder does exactly that on `forms` -- so clearing * the namespace wholesale deletes contributions the reload knows nothing * about and cannot put back. Singles registration documents the same hazard * and avoids it by never clearing at all, which trades a wipe for a leak. * * Reaches both stores, because `beforeOperation` lives apart and a partial * clear would leave one phase of a reloaded collection stale. */ clearCollectionOwnedBy(collection: string, owner: HookOwner): void; /** * Swap one owner's handlers for a collection, leaving them WHERE THEY WERE. * * Execution is in registration order, and owners interleave: a plugin * registers during its `init`, the config right after, and an app whenever the * module holding its `registerHook` call is evaluated -- which can be later * than both. Removing an owner's entries and appending the replacements would * move that owner behind everyone registered after it, so an unrelated config * save would silently reorder a transforming chain and change the data it * produces. The replacements go in at the index the first old one held, so a * reload perturbs nothing it is not replacing. */ replaceCollectionOwnedBy(collection: string, owner: HookOwner, replacement: OwnedHookSet): void; /** * Drop one owner's entries under a key and put `handlers` back at the index * the first of them held, appending only when the owner had none there. */ private spliceOwned; /** * The owners under one key, in the order their handlers run. * * Ordering between owners is a real part of the contract -- transforming * handlers feed each other -- and neither a count nor the handler snapshot * can express it, so it is observable rather than inferred from internals. * * @internal */ describeOwners(hookType: HookType, collection: string): HookOwner[]; /** * Every owner that currently holds at least one registration. * * A caller reconciling owners against a config needs to know who is REGISTERED, * not only who the config still mentions: a plugin deleted outright is absent * from the new config entirely, so a set derived from that config alone can * never name it, and it would go on running. */ registeredOwners(): HookOwner[]; /** * Every collection namespace holding at least one handler for `owner`. * * Lets a caller that rebuilds an owner's registrations find the namespaces it * is no longer going to rebuild -- a collection deleted or renamed in the * config still has its handlers here, and its table is deliberately retained * rather than dropped, so it stays addressable and would go on running them. * * The wildcard is never reported: it belongs to no single entity, so a caller * reconciling against a list of entities can only ever conclude it is absent. */ collectionsOwnedBy(owner: HookOwner): string[]; /** * Unregister all hooks for a specific collection * * Removes all hooks associated with a collection. * Useful when a collection is deleted or during testing cleanup. * * @param collection - Collection name or '*' for global hooks * * @example * ```typescript * // Remove all hooks for 'posts' collection * registry.clearCollection('posts'); * * // Remove all global hooks * registry.clearCollection('*'); * ``` */ clearCollection(collection: string): void; /** * Clear all hooks from the registry * * Removes all registered hooks for all collections. * Primarily used for testing cleanup. * * @example * ```typescript * // In test cleanup * afterEach(() => { * registry.clear(); * }); * ``` */ clear(): void; /** * Execute all registered hooks for a given type and collection * * Hooks run in series (one after another) in registration order. * Global wildcard hooks (*) execute BEFORE collection-specific hooks. * * **Execution Order:** * 1. Global hooks (registered with '*') * 2. Collection-specific hooks * * **Data Flow:** * - For `before*` hooks: Each hook can modify data, which is passed to the next hook * - For the after-write hooks in {@link SIDE_EFFECT_HOOK_TYPES}: return values * are ignored, so every handler and the caller see the persisted row * - For `afterRead`: the return reshapes the response and is passed on * * **Error Handling:** * - If any hook throws an error, execution stops immediately * - The error is propagated to the caller (usually CollectionsHandler) * - This will cause the database transaction to rollback * * @template T - Type of the data being operated on * @param hookType - Type of hook to execute * @param context - Hook context with operation metadata * @returns Modified data (for before hooks) or void * @throws Error if any hook fails * * @example * ```typescript * // beforeCreate hook modifies data * const modifiedData = await registry.execute('beforeCreate', { * collection: 'posts', * operation: 'create', * data: { title: 'My Post' }, * context: {} * }); * * // afterCreate hook runs side effects * await registry.execute('afterCreate', { * collection: 'posts', * operation: 'create', * data: createdPost, * context: sharedContext * }); * ``` */ execute(hookType: HookContextPhase, context: HookContext, options?: { /** * Called for each side-effect handler that throws, so the caller can * report it alongside the successful write. Omitting it does not make * the failure silent -- it is logged either way. */ onSideEffectError?: (failure: SideEffectHookFailure) => void; }): Promise; /** * Execute beforeOperation hooks for a collection * * beforeOperation hooks run BEFORE operation-specific hooks (beforeCreate, etc.) * and can modify operation arguments or throw to abort the operation. * * **Execution Order:** * 1. Global beforeOperation hooks (registered with '*') * 2. Collection-specific beforeOperation hooks * 3. Then operation-specific hooks (beforeCreate, beforeUpdate, etc.) * * **Args Flow:** * - Each hook can modify args (data, id, where), which is passed to the next hook * - If hook returns undefined/void, args remain unchanged * - If hook throws, operation is aborted * * **Use Cases:** * - Global logging/auditing of all operations * - Rate limiting across all operations * - Global validation or normalization * - Modifying operation arguments before they reach specific hooks * * @template T - Type of the data being operated on * @param context - BeforeOperation context with operation metadata and args * @returns Modified args or void (if no modification) * @throws Error if any hook fails * * @example * ```typescript * // Global logging for all operations * registry.registerBeforeOperation('*', async (context) => { * console.log(`[${context.operation}] ${context.collection}`, context.args); * }); * * // Execute beforeOperation hooks * const modifiedArgs = await registry.executeBeforeOperation({ * collection: 'posts', * operation: 'create', * args: { data: { title: 'My Post' } }, * context: {} * }); * * // Use modifiedArgs.data for the actual create operation * ``` */ executeBeforeOperation(context: BeforeOperationContext): Promise | void>; /** * Check if any hooks are registered for a given type/collection * * Performance optimization: Allows callers to skip hook execution * if no hooks are registered, avoiding unnecessary overhead. * * @param hookType - Type of hook to check * @param collection - Collection name * @returns True if hooks are registered (global or specific) * * @example * ```typescript * if (registry.hasHooks('beforeCreate', 'posts')) { * const modifiedData = await registry.execute('beforeCreate', context); * } * ``` */ hasHooks(hookType: HookType, collection: string): boolean; /** * How many handlers under one key would actually run. * * A suspended owner's entries stay registered but never execute, so a * presence check that counted them would tell a caller to run a phase that * does nothing -- and `hasHooks` exists precisely so a caller can skip that * work. Registration counts stay raw in {@link getHookCount}, which is * introspection rather than a decision. */ private runnableCountAt; /** * How many handlers one key holds, in whichever store owns that phase. * * Introspection stays whole-registry -- a caller asking whether a phase has * hooks means every phase, including `beforeOperation` -- so the split in * storage must not become a split in what can be counted. */ private countAt; /** * Get count of registered hooks for a specific type/collection * * Useful for debugging and monitoring. * * @param hookType - Type of hook * @param collection - Collection name or '*' * @returns Number of registered hooks * * @example * ```typescript * const count = registry.getHookCount('beforeCreate', 'posts'); * console.log(`${count} beforeCreate hooks registered for posts`); * ``` */ getHookCount(hookType: HookType, collection: string): number; /** * Get all registered hooks (for debugging/introspection) * * Returns a snapshot of all registered hooks. * Useful for debugging and testing. * * Excludes `beforeOperation`, whose handlers take a different context and are * stored separately -- see {@link getAllBeforeOperation}. * * @returns Map of hook keys to handler arrays * @internal */ getAll(): Map; /** * Snapshot of the registered `beforeOperation` hooks, the counterpart to * {@link getAll}. * * @returns Map of hook keys to handler arrays * @internal */ getAllBeforeOperation(): Map; /** * Generate storage key for hook type + collection * @private */ private makeKey; } /** * Get the global hook registry singleton * * Always use this function to access the registry to ensure * a single instance is shared across the application. * * @returns Global HookRegistry instance * * @example * ```typescript * import { getHookRegistry } from 'nextly/hooks'; * * const registry = getHookRegistry(); * registry.register('beforeCreate', 'posts', myHook); * ``` */ declare function getHookRegistry(): HookRegistry; /** * Clear every hook from the global registry. * * Called when services shut down or are cleared, because the registry outlives * the DI container: handlers are registered from config on each init, so a * registry left populated would hand a second instance in the same process a * duplicate of every handler plus the dead instance's own. * * @internal * @example * ```typescript * // In test cleanup * afterEach(() => { * resetHookRegistry(); * }); * ``` */ declare function resetHookRegistry(): void; /** * Request-scoped diagnostics: what a request collected that its public * response cannot carry. * * A side-effect phase (`afterCreate` / `afterUpdate` / `afterDelete`) runs after * the transaction has committed, so a handler throwing there cannot un-save the * row. The operation therefore reports success, and the failure is reported * beside it as a warning: a side effect that silently did not run is the outcome * that has to be avoided. * * The failures are produced deep in the hook registry and consumed at the * response boundary, with the whole write path in between. Threading a callback * through that path means editing every write path to carry a parameter none of * them otherwise needs, and every response builder to forward it. An ambient * scope lets the producer stay one line and the consumers stay one function * each. * * @module hooks/side-effect-warnings */ /** * The public projection of a hook failure. * * Deliberately not `SideEffectHookFailure`, whose `error` is a `NextlyError` * carrying `cause` and `logContext`. Those are private diagnostics — * `errorToServiceResult` exists to keep identifier-bearing detail off publicly * surfaced shapes — so a client gets the code and the public message, and the * full error stays on the collector for the logger. */ interface HookWarning { /** The lifecycle phase whose handler failed. */ phase: string; /** * The registry key the handler was registered against. * * A collection's slug, or `single:` for a single. Namespaced rather * than bare because a collection and a single may share a slug, and a * consumer reacting to the warning has to know which one it came from. */ collection: string; /** The canonical `NextlyError` code, for a caller branching on the failure. */ code: string; /** The §13.8-compliant public message. Never carries identifiers. */ message: string; /** * The row whose side effect failed, when the phase knows it. * * The caller supplied this id or is being handed it back in the same * response, so it discloses nothing new — and without it a bulk caller * cannot tell which of its durable rows to remediate. */ entryId?: string; } /** * Base Field Types and Interfaces * * This module provides the foundational type definitions for Nextly's field system. * All specific field types (text, number, select, etc.) extend from BaseFieldConfig. * * Inspired by modern CMS field patterns, adapted for Nextly's architecture. * * @module collections/fields/types/base * @since 1.0.0 */ /** * All supported field types in Nextly. * * Field types are categorized as: * - **Text types:** text, textarea, richText, email, password, code * - **Numeric types:** number * - **Selection types:** checkbox, date, select, radio * - **Media types:** upload * - **Relational types:** relationship * - **Structured types:** repeater, group, json, component, chips */ type FieldType = "text" | "textarea" | "richText" | "email" | "password" | "code" | "number" | "checkbox" | "date" | "select" | "radio" | "upload" | "relationship" | "repeater" | "group" | "json" | "component" | "chips"; /** * Request context passed to access control and hook functions. * * Contains information about the current user, locale, and HTTP request. * Used by access control functions to determine field-level permissions * and by hooks for context-aware processing. * * @example * ```typescript * const accessFn: AccessFunction = ({ req }) => { * // Only admins can access this field * return req.user?.role === 'admin'; * }; * ``` */ interface RequestContext$1 { /** * The authenticated user making the request. * Undefined if the request is unauthenticated. */ user?: { /** Unique user identifier */ id: string; /** User's email address */ email?: string; /** User's role (e.g., 'admin', 'editor', 'user') */ role?: string; /** * User's roles (many-to-many). Role-based access rules match if ANY of * these roles is allowed; `role` is folded in when present. */ roles?: string[]; /** Additional user properties */ [key: string]: unknown; }; /** * Current locale for localized content. * Used when localization is enabled for a field. */ locale?: string; /** * Fallback locale (or `false` to disable fallback) for localized reads. */ fallbackLocale?: string | false; /** * HTTP request metadata. * Available when the operation originates from an HTTP request. */ req?: { /** HTTP request headers */ headers?: Record; /** Query string parameters */ query?: Record; }; } /** * Function signature for field-level access control. * * Access functions determine whether a user can perform a specific * operation (create, read, update) on a field. * * @param args - Object containing request context, document ID, and data * @returns `true` to allow access, `false` to deny access * * @example * ```typescript * // Only allow admins to update the 'status' field * const canUpdateStatus: AccessFunction = ({ req }) => { * return req.user?.role === 'admin'; * }; * * // Allow users to read their own data only * const canReadOwnData: AccessFunction = ({ req, id }) => { * return req.user?.id === id; * }; * ``` */ type AccessFunction = (args: { /** Request context with user and locale information */ req: RequestContext$1; /** Document ID (available for read/update operations) */ id?: string; /** Document data being created or updated */ data?: Record; /** * The caller's effective permissions, as `resource:action` — the SAME * spelling collection-level access control receives, so a rule reads the * same string wherever it is written. * * Note that this is not how a permission is spelled in the database or in * the admin's permission matrix, where it is `action-resource`. The two are * composed from one row and mean the same thing; only the string differs. * * Empty for an unauthenticated caller, and empty if the lookup fails — a * rule that asks for a permission therefore denies rather than opens when * grants cannot be read. */ permissions: string[]; /** The caller's role slugs, including roles inherited from other roles. */ roles: string[]; }) => boolean | Promise; /** * Field-level access control configuration. * * Defines granular permissions for create, read, and update operations * on a specific field. If not specified, access defaults to `true`. * * @example * ```typescript * const passwordAccess: FieldAccess = { * // Anyone can set password on create * create: () => true, * // Only the user themselves can read (actually never return it) * read: () => false, * // Only admins or the user themselves can update * update: ({ req, id }) => req.user?.role === 'admin' || req.user?.id === id, * }; * ``` */ interface FieldAccess { /** Access control for field creation */ create?: AccessFunction; /** Access control for field reading */ read?: AccessFunction; /** Access control for field updates */ update?: AccessFunction; } /** * Conditional logic for field visibility and behavior. * * Allows fields to be shown, hidden, or modified based on * the values of other fields in the document. * * @example * ```typescript * // Show 'externalUrl' field only when 'linkType' is 'external' * const showExternalUrl: FieldCondition = { * field: 'linkType', * equals: 'external', * }; * * // Show 'customMessage' only when 'useCustomMessage' exists and is true * const showCustomMessage: FieldCondition = { * field: 'useCustomMessage', * exists: true, * }; * ``` */ interface FieldCondition$1 { /** The field name to evaluate */ field: string; /** Show this field when the target field equals this value */ equals?: unknown; /** Show this field when the target field does NOT equal this value */ notEquals?: unknown; /** Show this field when the target field contains this string */ contains?: string; /** Show this field when the target field exists (or doesn't exist) */ exists?: boolean; } /** * Shared validation knobs available on field types that support them. * * Mirrors the nested shape the Schema Builder writes * (`field.validation.pattern` etc.) so code-first config and the Builder UI * converge on one source of truth. The renderer reads either the flat or * nested form, but new code-first usage should prefer the nested * `validation` object since it groups related knobs together. * * @example * ```typescript * text({ * name: "slug", * required: true, * validation: { pattern: "^[a-z-]+$", message: "Slug must be lowercase" }, * }) * ``` */ interface FieldValidation { /** Mark the field as required (mirrors the top-level `required` flag). */ required?: boolean; /** * Regex pattern (string form) applied to string values. For optional * fields, empty values bypass the pattern; for required fields the * pattern always runs. */ pattern?: string; /** Custom error message displayed when validation fails. */ message?: string; /** Minimum string length (for text-like fields). */ minLength?: number; /** Maximum string length (for text-like fields). */ maxLength?: number; /** Minimum numeric value (for number fields). */ min?: number; /** Maximum numeric value (for number fields). */ max?: number; /** Minimum number of items in a hasMany / repeater field. */ minRows?: number; /** Maximum number of items in a hasMany / repeater field. */ maxRows?: number; } /** * Admin panel configuration options for fields. * * Controls how the field appears and behaves in the Admin UI, * including layout, styling, and custom components. * * @example * ```typescript * const adminOptions: FieldAdminOptions = { * width: '50%', * description: 'Enter the product SKU', * placeholder: 'e.g., SKU-12345', * condition: { * field: 'productType', * equals: 'physical', * }, * }; * ``` */ interface FieldAdminOptions { /** * Position the field in the sidebar instead of the main content area. * Only 'sidebar' is currently supported. */ position?: "sidebar"; /** * Width of the field in the form layout. * Uses CSS percentage values for responsive grid layouts. */ width?: "25%" | "33%" | "50%" | "66%" | "75%" | "100%"; /** * Custom inline styles to apply to the field wrapper. */ style?: Record; /** * Custom CSS class name(s) to apply to the field wrapper. */ className?: string; /** * Make the field read-only in the Admin UI. * The field value can still be set programmatically. */ readOnly?: boolean; /** * Hide the field from the Admin UI entirely. * The field still exists in the schema and can be set via API. */ hidden?: boolean; /** * Disable the field input in the Admin UI. * Similar to readOnly but with different visual styling. */ disabled?: boolean; /** * @experimental Override the admin field editor with a plugin-registered * component, by string path (D24, e.g. `"@acme/plugin/admin#ColorPicker"`). * Takes precedence over the built-in type dispatch; rendered inside the plugin * error boundary. Register the component via `@nextlyhq/plugin-sdk/admin`. */ component?: string; /** * Conditional logic for showing/hiding the field. * The field is hidden when the condition evaluates to false. */ condition?: FieldCondition$1; /** * Help text displayed below the field label. * Use this to provide additional context or instructions. */ description?: string; /** * Placeholder text displayed in the input when empty. */ placeholder?: string; /** * Custom React components to override default field rendering. */ components?: { /** * Custom component for rendering the field in forms. * Receives field props including value, onChange, etc. */ Field?: React.ComponentType; /** * Custom component for rendering the field in list/table views. * Receives the cell value and row data. */ Cell?: React.ComponentType; /** * Custom component for rendering the field's filter UI. * Used in list views for filtering by this field. */ Filter?: React.ComponentType; }; } /** * Props passed to custom Field components. */ interface FieldComponentProps { /** Current field value */ value: unknown; /** Callback to update the field value */ onChange: (value: unknown) => void; /** Field configuration */ field: BaseFieldConfig; /** Path to the field in nested structures */ path: string; /** Whether the field is read-only */ readOnly?: boolean; /** Whether the field is disabled */ disabled?: boolean; /** Validation error message, if any */ error?: string; } /** * Props passed to custom Cell components (list view). */ interface CellComponentProps { /** Cell value to display */ value: unknown; /** Full row data */ rowData: Record; /** Field configuration */ field: BaseFieldConfig; /** Collection slug */ collection: string; } /** * Props passed to custom Filter components. */ interface FilterComponentProps { /** Current filter value */ value: unknown; /** Callback to update the filter value */ onChange: (value: unknown) => void; /** Field configuration */ field: BaseFieldConfig; } /** * Field-level hooks configuration. * * Hooks allow custom logic to run at specific points in a field's lifecycle. * Unlike collection-level hooks, field hooks operate on individual field values. * * @example * ```typescript * const slugHooks: FieldHooks = { * beforeValidate: [ * async ({ value, data }) => { * // Auto-generate slug from title if not provided * if (!value && data?.title) { * return slugify(data.title); * } * return value; * }, * ], * }; * ``` */ interface FieldHooks { /** * Runs before field validation. * Can transform the field value before validation rules are applied. */ beforeValidate?: FieldHookHandler[]; /** * Runs before the field value is saved to the database. * Can transform the final value to be stored. */ beforeChange?: FieldHookHandler[]; /** * Runs after the field value has been saved to the database. * Useful for side effects like sending notifications. */ afterChange?: FieldHookHandler[]; /** * Runs after the field value is read from the database. * Can transform the value before it's returned to the client. */ afterRead?: FieldHookHandler[]; } /** * Base field configuration interface. * * All specific field types (text, number, select, etc.) extend this interface. * Contains common properties shared by all field types. * * @example * ```typescript * // A simple text field configuration * const titleField: BaseFieldConfig = { * name: 'title', * type: 'text', * label: 'Title', * required: true, * admin: { * description: 'Enter the post title', * }, * }; * ``` */ interface BaseFieldConfig { /** * Unique field name (identifier). * * Must be unique within the collection and follow naming conventions: * - Start with a lowercase letter * - Contain only lowercase letters, numbers, and underscores * - Not be a reserved SQL keyword * * @example 'title', 'created_at', 'user_id' */ name: string; /** * Field type identifier. * * Determines the field's behavior, validation, and UI rendering. */ type: FieldType; /** * Human-readable label displayed in the Admin UI. * * If not provided, the label is auto-generated from the field name * (e.g., 'user_name' becomes 'User Name'). */ label?: string; /** * Whether the field is required. * * Required fields must have a non-null, non-empty value. * @default false */ required?: boolean; /** * Whether the field value must be unique across all documents. * * Enforced at the database level with a unique constraint. * @default false */ unique?: boolean; /** * Whether to create a database index on this field. * * Indexes improve query performance for frequently searched fields. * @default false */ index?: boolean; /** * Default value for the field. * * Can be a static value or a function that returns a value. * The function receives the document data being created. * * @example * ```typescript * // Static default * defaultValue: 'draft' * * // Dynamic default * defaultValue: () => new Date().toISOString() * ``` */ defaultValue?: (data: Record) => unknown; /** * Admin UI configuration options. * * Controls field appearance, behavior, and custom components. */ admin?: FieldAdminOptions; /** * Field-level access control. * * Defines who can create, read, and update this field. */ access?: FieldAccess; /** * Field-level lifecycle hooks. * * Custom logic that runs at specific points in the field's lifecycle. */ hooks?: FieldHooks; /** * Custom validation function. * * Runs after built-in validation. Return `true` for valid, * or a string error message for invalid. * * @example * ```typescript * validate: (value, { data }) => { * if (value && value.length < 3) { * return 'Must be at least 3 characters'; * } * return true; * } * ``` */ validate?: (value: unknown, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; /** * Custom metadata for plugins and extensions. * * Store arbitrary data that can be used by custom components, * hooks, or plugins. */ custom?: Record; /** * Whether this field supports localization. * * When `true`, the field stores separate values for each locale. * Requires localization to be enabled in the collection config. * * Overrides the per-type smart default (text-like fields localize by default; * value/structural fields are shared). `password` is never localizable. * * @default false */ localized?: boolean; } /** * Rich Text Field Type * * A rich text editor field powered by Lexical. * Supports configurable features like formatting, links, lists, * headings, and embedded content. * * @module collections/fields/types/rich-text * @since 1.0.0 */ /** * Available rich text editor features. * * Features can be selectively enabled/disabled to customize * the editing experience. By default, all basic formatting * features are enabled. * * **Formatting Features:** * - `bold` - Bold text * - `italic` - Italic text * - `underline` - Underlined text * - `strikethrough` - Strikethrough text * - `code` - Inline code * - `subscript` - Subscript text * - `superscript` - Superscript text * * **Block Features:** * - `blockquote` - Block quotes * - `h1` through `h6` - Heading levels * * **List Features:** * - `orderedList` - Numbered lists * - `unorderedList` - Bullet lists * - `checkList` - Checkbox lists * - `indent` - Indentation control * * **Link & Media Features:** * - `link` - Hyperlinks * - `upload` - Embedded uploads/media * - `relationship` - Embedded document references * * **Advanced Features:** * - `table` - Tables * - `horizontalRule` - Horizontal dividers * - `codeBlock` - Code blocks with syntax highlighting * - `align` - Text alignment (left, center, right, justify) * * **Text Styling Features:** * - `fontFamily` - Font family selector * - `fontSize` - Font size selector * - `fontColor` - Text color picker * - `bgColor` - Background color picker * * **Rich Media Features:** * - `video` - Embedded YouTube/Vimeo videos * - `buttonLink` - Styled button links * - `collapsible` - Collapsible/accordion sections * - `gallery` - Multi-image galleries */ type RichTextFeature = "bold" | "italic" | "underline" | "strikethrough" | "code" | "subscript" | "superscript" | "fontFamily" | "fontSize" | "fontColor" | "bgColor" | "blockquote" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "orderedList" | "unorderedList" | "checkList" | "indent" | "link" | "upload" | "relationship" | "table" | "horizontalRule" | "codeBlock" | "align" | "video" | "buttonLink" | "collapsible" | "gallery"; /** * Rich text field value structure. * * The value is stored as a JSON object representing the Lexical * editor state. This includes the root node with all child nodes. * * @example * ```json * { * "root": { * "type": "root", * "children": [ * { * "type": "paragraph", * "children": [ * { "type": "text", "text": "Hello " }, * { "type": "text", "text": "world", "format": 1 } * ] * } * ] * } * } * ``` */ interface RichTextValue { /** * The root node of the Lexical editor state. */ root: { type: "root"; children: RichTextNode[]; [key: string]: unknown; }; } /** * A node in the rich text structure. * * Nodes can be paragraphs, headings, lists, text, or other * content types. Each node has a type and may have children. */ interface RichTextNode { /** * The node type (e.g., 'paragraph', 'text', 'heading'). */ type: string; /** * Child nodes (for container nodes like paragraphs). */ children?: RichTextNode[]; /** * Text content (for text nodes). */ text?: string; /** * Text formatting flags (bold, italic, etc.). */ format?: number; /** * Additional node-specific properties. */ [key: string]: unknown; } /** * Possible value types for a rich text field. */ type RichTextFieldValue = RichTextValue | null | undefined; /** * Admin panel options specific to rich text fields. * * Extends the base admin options with rich text-specific settings. */ interface RichTextFieldAdminOptions extends FieldAdminOptions { /** * Hide the editor toolbar. * * When `true`, the toolbar is hidden and users can only * use keyboard shortcuts for formatting. * * @default false */ hideToolbar?: boolean; } /** * Configuration interface for rich text fields. * * Rich text fields provide a full-featured text editor powered by * Lexical. They support formatting, links, lists, headings, and * can be configured with custom features. * * **Note:** Only the Lexical editor is supported. The editor * property is not needed as Lexical is the default and only option. * * @example * ```typescript * // Basic rich text field with default features * const contentField: RichTextFieldConfig = { * name: 'content', * type: 'richText', * label: 'Content', * required: true, * }; * * // Rich text with limited features * const simpleContentField: RichTextFieldConfig = { * name: 'simpleContent', * type: 'richText', * label: 'Simple Content', * features: ['bold', 'italic', 'link', 'orderedList', 'unorderedList'], * }; * * // Rich text with all features * const fullContentField: RichTextFieldConfig = { * name: 'fullContent', * type: 'richText', * label: 'Full Content', * features: [ * 'bold', 'italic', 'underline', 'strikethrough', 'code', * 'h1', 'h2', 'h3', 'blockquote', * 'orderedList', 'unorderedList', 'checkList', * 'link', 'upload', 'table', * ], * }; * ``` */ interface RichTextFieldConfig extends Omit { /** * Field type identifier. Must be 'richText'. */ type: "richText"; /** * Enabled editor features. * * If not specified, a default set of features is enabled: * - Formatting: bold, italic, underline, strikethrough, code * - Headings: h1, h2, h3, h4 * - Lists: orderedList, unorderedList, indent * - Other: blockquote, link * * Set to an empty array `[]` to start with a plain text editor. */ features?: RichTextFeature[]; /** * Default value for the field. * * Can be a static RichTextValue or a function that returns one. */ defaultValue?: RichTextValue | ((data: Record) => RichTextValue); /** * Admin UI configuration options. */ admin?: RichTextFieldAdminOptions; /** * Custom validation function. * * Receives the typed rich text value and returns `true` for valid * or an error message string for invalid. * * @param value - The rich text field value * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * validate: (value, { data }) => { * if (!value || !value.root.children.length) { * return 'Content is required'; * } * return true; * } * ``` */ validate?: (value: RichTextFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Rich Text HTML Conversion Utilities * * Provides server-side conversion of Lexical Rich Text JSON to HTML. * Uses a custom serializer that doesn't require DOM/JSDOM for server-side rendering. * * @module lib/rich-text-html * @since 1.0.0 */ type RichTextOutputFormat = "json" | "html" | "both"; /** * Direct API Forms Type Definitions * * Configuration and argument types for the `nextly.forms.*` namespace. * * @packageDocumentation */ /** * Configuration for the forms API namespace. * * Allows overriding the default collection slugs used by the form builder plugin. * If the plugin uses custom collection slugs, provide them here. * * @example * ```typescript * const nextly = new Nextly({ * forms: { * collectionSlug: 'contact-forms', * submissionCollectionSlug: 'contact-responses', * }, * }); * ``` */ interface FormsConfig { /** * Slug of the forms collection. * * Must match the `formOverrides.slug` in your form builder plugin config. * * @default "forms" */ collectionSlug?: string; /** * Slug of the form submissions collection. * * Must match the `formSubmissionOverrides.slug` in your form builder plugin config. * * @default "form-submissions" */ submissionCollectionSlug?: string; } /** * Arguments for finding published forms. * * @example * ```typescript * // List all published forms * const forms = await nextly.forms.find({ status: 'published' }); * * // List with pagination * const forms = await nextly.forms.find({ limit: 10, page: 1 }); * ``` */ interface FindFormsArgs extends DirectAPIConfig { /** Filter by form status */ status?: "published" | "draft" | "closed"; /** Search by form name */ search?: string; /** Maximum number of forms to return */ limit?: number; /** Page number */ page?: number; } /** * Arguments for finding a form by slug. * * @example * ```typescript * const form = await nextly.forms.findBySlug({ slug: 'contact-form' }); * ``` */ interface FindFormBySlugArgs extends DirectAPIConfig { /** Form slug (required) */ slug: string; } /** * Arguments for submitting a form. * * This performs a basic submission flow: * 1. Fetches the form by slug * 2. Verifies the form is published * 3. Creates a submission record * * For advanced features (spam detection, Zod validation, webhooks), * use the form builder plugin's `submitForm()` handler directly. * * @example * ```typescript * const result = await nextly.forms.submit({ * form: 'contact-form', * data: { * name: 'John Doe', * email: 'john@example.com', * message: 'Hello!', * }, * }); * * if (result.success) { * console.log('Submission created:', result.submission.id); * } * ``` */ interface SubmitFormArgs extends DirectAPIConfig { /** Form slug (required) */ form: string; /** Form submission data (required) */ data: Record; /** * Optional metadata about the submission. * * Useful for tracking IP addresses and user agents * when processing submissions server-side. */ metadata?: { /** Submitter's IP address */ ipAddress?: string; /** Submitter's user agent string */ userAgent?: string; }; } /** * Result of a form submission. */ interface SubmitFormResult { /** Whether the submission was successful */ success: boolean; /** The created submission record (on success) */ submission?: Record; /** Error message (on failure) */ error?: string; /** Redirect URL (if form configured for redirect on success) */ redirect?: string; /** * Side effects that failed after the submission was saved, when any did. * * A submission collection's `afterCreate` hooks are where notification and * integration work lives, so one throwing is the common case here: the row * is durable and the caller has to be able to tell that the email or the * webhook did not go out. */ warnings?: HookWarning[]; } /** * Arguments for retrieving form submissions. * * @example * ```typescript * // Get submissions for a form * const result = await nextly.forms.submissions({ * form: 'contact-form', * limit: 20, * page: 1, * }); * * console.log(result.items); // Submission records * console.log(result.meta.total); // Total count * ``` */ interface FormSubmissionsArgs extends DirectAPIConfig { /** Form slug or ID (required) */ form: string; /** Maximum number of submissions to return */ limit?: number; /** Page number */ page?: number; /** Sort order (e.g., '-submittedAt' for newest first) */ sort?: string; } /** * Pagination Types * * Provides pagination response types and utilities. */ /** * Standard paginated response format. * * This interface defines the response structure returned for all * paginated queries. * * @template T - The type of documents in the response * * @example * ```typescript * const response: PaginatedResponse = { * docs: [{ id: '1', title: 'Hello' }], * totalDocs: 100, * limit: 10, * totalPages: 10, * page: 1, * pagingCounter: 1, * hasPrevPage: false, * hasNextPage: true, * prevPage: null, * nextPage: 2, * }; * ``` */ interface PaginatedResponse { /** Array of documents for the current page */ docs: T[]; /** Total number of documents matching the query */ totalDocs: number; /** Maximum number of documents per page */ limit: number; /** Total number of pages available */ totalPages: number; /** Current page number (1-indexed) */ page: number; /** * Index of the first document on the current page (1-indexed). * For example, on page 2 with limit 10, pagingCounter would be 11. */ pagingCounter: number; /** Whether there is a previous page */ hasPrevPage: boolean; /** Whether there is a next page */ hasNextPage: boolean; /** Previous page number, or null if on the first page */ prevPage: number | null; /** Next page number, or null if on the last page */ nextPage: number | null; } /** * Options for building a paginated response. */ interface BuildPaginatedResponseOptions { /** Total number of documents matching the query (before pagination) */ total: number; /** Current page number (1-indexed) */ page: number; /** Number of documents per page */ limit: number; } /** * Default pagination values. */ declare const PAGINATION_DEFAULTS: { /** Default page number */ readonly page: 1; /** Default number of documents per page */ readonly limit: 10; /** Maximum allowed limit to prevent abuse */ readonly maxLimit: 500; }; /** * Builds a complete paginated response from documents and pagination options. * * This utility function calculates all pagination metadata fields * based on the total count, current page, and limit. * * @template T - The type of documents in the response * @param docs - Array of documents for the current page * @param options - Pagination options including total count, page, and limit * @returns Complete paginated response with all metadata fields * * @example * ```typescript * const entries = await db.select().from(posts).limit(10).offset(0); * const total = await db.select({ count: sql`count(*)` }).from(posts); * * const response = buildPaginatedResponse(entries, { * total: Number(total[0].count), * page: 1, * limit: 10, * }); * // Returns: { docs, totalDocs, limit, totalPages, page, pagingCounter, ... } * ``` */ declare function buildPaginatedResponse(docs: T[], options: BuildPaginatedResponseOptions): PaginatedResponse; /** * Clamps a limit value to be within valid bounds. * * @param limit - The requested limit value * @param maxLimit - Maximum allowed limit (default: 500) * @returns Clamped limit value between 1 and maxLimit */ declare function clampLimit(limit: number, maxLimit?: number): number; /** * Calculates the SQL OFFSET value for pagination. * * @param page - Current page number (1-indexed) * @param limit - Number of documents per page * @returns Offset value for SQL query */ declare function calculateOffset(page: number, limit: number): number; /** * Query operators for filtering collection entries. * * This module provides mapping between Nextly query operator syntax and * the internal adapter-drizzle WhereClause format. It supports operators * for filtering, comparison, and existence checks. * * @example * ```typescript * import { buildWhereClause, WhereFilter } from './query-operators'; * * // Simple equality * const where: WhereFilter = { status: { equals: 'published' } }; * const adapterWhere = buildWhereClause(where); * * // Complex query with AND/OR * const complexWhere: WhereFilter = { * and: [ * { status: { equals: 'published' } }, * { or: [ * { author: { equals: 'john' } }, * { author: { equals: 'jane' } } * ]} * ] * }; * ``` * * @packageDocumentation */ /** * Query operators for filtering collection entries. * * These operators are used in Nextly's REST API and Direct API * for filtering, comparison, and existence checks. */ type QueryOperator = "equals" | "not_equals" | "greater_than" | "greater_than_equal" | "less_than" | "less_than_equal" | "like" | "contains" | "search" | "in" | "not_in" | "exists"; /** * Field condition with query operators. * * @example * ```typescript * // Simple equality * const condition: FieldCondition = { equals: 'published' }; * * // Numeric comparison * const priceCondition: FieldCondition = { greater_than: 100 }; * * // Array membership * const tagsCondition: FieldCondition = { in: ['news', 'featured'] }; * ``` */ type FieldCondition = { [K in QueryOperator]?: unknown; }; /** * WHERE clause structure for filtering collection entries. * * Supports both simple field conditions and compound AND/OR queries. * * @example * ```typescript * // Simple field query * const where: WhereFilter = { * status: { equals: 'published' }, * price: { greater_than: 100 } * }; * * // Compound OR query * const orWhere: WhereFilter = { * or: [ * { color: { equals: 'red' } }, * { color: { equals: 'blue' } } * ] * }; * * // Complex nested query * const complexWhere: WhereFilter = { * and: [ * { status: { equals: 'active' } }, * { or: [ * { role: { equals: 'admin' } }, * { role: { equals: 'editor' } } * ]} * ] * }; * ``` */ interface WhereFilter { /** AND conditions - all must be true */ and?: WhereFilter[]; /** OR conditions - at least one must be true */ or?: WhereFilter[]; /** Field conditions (dynamic keys) */ [field: string]: FieldCondition | WhereFilter[] | undefined; } /** * Shared Direct API Type Definitions * * Cross-cutting types used by all domain namespaces: generated-type resolution * helpers, the base `DirectAPIConfig`, and request/user context types. * * @packageDocumentation */ /** * Canonical Direct API list-response shape. * * Phase 4 alignment: in-process find() / namespace.find() calls return * `{ items, meta }` (matching the wire API's `respondList` envelope) so * callers see the same shape regardless of transport. * * Migrate from the legacy `{ docs, totalDocs, ... }` shape: * - `result.docs` -> `result.items` * - `result.totalDocs` -> `result.meta.total` * - `result.hasNextPage` -> `result.meta.hasNext` * - `result.hasPrevPage` -> `result.meta.hasPrev` * * @typeParam T - Element type for each item in the list */ interface ListResult { /** Page of items for the current query slice. */ items: T[]; /** Pagination metadata. */ meta: PaginationMeta; } /** * Canonical Direct API mutation-response shape. * * Phase 4 alignment: create/update/delete return `{ message, item }` * (matching the wire API's `respondMutation` envelope). The `message` is a * server-authored toast string callers can surface verbatim; `item` is the * affected document (or a minimal `{ id }` shape for deletes). * * @typeParam T - Item type returned by the mutation */ interface MutationResult { /** Human-readable status message (e.g. "Post created."). */ message: string; /** The affected item. */ item: T; /** * Side effects that failed after the write committed, when any did. * * A post-commit hook cannot un-save the row, so the operation reports * success and the failure travels beside it. Absent when every hook * succeeded, so an ordinary result is unchanged. * * Mirrors the `warnings` field on the wire API's mutation envelope, so the * same failure is equally visible whether the caller came through REST or * called the Direct API in-process. */ warnings?: HookWarning[]; } /** * Interface augmented by generated types. * * Running `nextly generate:types` creates a `Config` interface mapping * collection and single slugs to their TypeScript types, then augments * this interface via module declaration: * * ```typescript * // In generated payload-types.ts: * declare module "nextly" { * export interface GeneratedTypes extends Config {} * } * ``` * * When augmented, Direct API methods gain full type inference: * - Collection slugs are constrained to valid slugs * - Return types resolve to the correct document type * - Invalid slugs produce compile-time errors */ interface GeneratedTypes { } /** * Collection slug type. * * When generated types exist, this resolves to a union of valid collection * slug literals (e.g., `'posts' | 'users'`). Without generated types, * falls back to `string` for maximum flexibility. */ type CollectionSlug = GeneratedTypes extends { collections: infer C; } ? keyof C & string : string; /** * Single slug type. * * When generated types exist, this resolves to a union of valid single * slug literals (e.g., `'site-settings' | 'header'`). Without generated * types, falls back to `string`. */ type SingleSlug = GeneratedTypes extends { singles: infer C; } ? keyof C & string : string; /** * Resolves the document type for a given collection slug. * * When generated types exist and the slug maps to a known collection, * returns the corresponding TypeScript interface. Otherwise returns * `Record`. * * @typeParam TSlug - The collection slug string literal * * @example * ```typescript * // With generated types: * type PostDoc = DataFromCollectionSlug<'posts'>; // → Post interface * * // Without generated types: * type AnyDoc = DataFromCollectionSlug; // → Record * ``` */ type DataFromCollectionSlugFrom = TGenerated extends { collections: infer C; } ? TSlug extends keyof C ? C[TSlug] : Record : Record; type DataFromCollectionSlug = DataFromCollectionSlugFrom; /** * Resolves the document type for a given single/global slug. * * @typeParam TSlug - The single slug string literal */ type DataFromSingleSlugFrom = TGenerated extends { singles: infer C; } ? TSlug extends keyof C ? C[TSlug] : Record : Record; type DataFromSingleSlug = DataFromSingleSlugFrom; /** * The timestamp fields every entity carries, whatever its own fields are. * * Used as the in-process shape for a project whose generated types predate * `collectionDateFields`: the built-in timestamps are true of every collection, * so they can be named without consulting the schema. */ type BuiltInDateField = "createdAt" | "updatedAt"; /** * A document as it exists inside the running process, given the fields the * database returns as `Date`. * * The generated interfaces describe the WIRE: `routeHandler` formats every REST * response, so a timestamp really is a string by the time a browser sees it. In * process there is no such step and a timestamp column arrives as the `Date` the * driver decoded, so the two shapes differ in exactly those fields. * * Homomorphic on purpose: `?` and `readonly` are carried over, so an optional * `publishedAt?: string` stays optional rather than becoming required. * * TOP LEVEL ONLY. A relationship field is typed `string | Related`, and at a * depth that populates it the related row carries decoded `Date`s while that * `Related` interface still spells them as strings. Reaching into it needs the * generated types to record which fields are relations and to what, which this * mapping has no way to know from the document type alone. A date nested in a * field group or repeater needs nothing: those are stored as JSON, so their * dates really are strings in process too. */ type InProcessRow = { [K in keyof TData]: K extends TDateField ? InProcessDate : TData[K]; }; /** * What a timestamp column hands back, given how the generated type spells the * field it belongs to. * * A date that the schema requires is always a `Date`. An OPTIONAL one is * `Date | null`, and the `null` is not decoration: codegen writes `?` exactly * when a field is not required, a field that is not required has a nullable * column, and a nullable timestamp column reads back as `null` -- the same * answer on PostgreSQL, MySQL and SQLite, with the key present on the row. * * So `undefined` is the one value a full read never produces and `null` is the * one it always produces for an unset date. `?` is still carried over by the * mapping above, because a projected read can leave the key off entirely; what * matters here is that `null` can no longer be narrowed away. A caller who * checks `!== undefined` and then calls a `Date` method is the failure this * exists to make impossible. * * A source type that already states its own `null` keeps it, so this stays * correct if the generated interfaces start spelling nullability themselves. */ type InProcessDate = [undefined] extends [TValue] ? Date | null | Extract : Date | Extract; /** * The `Date`-backed field names of a collection, as codegen recorded them. * * Falls back to the built-in timestamps when a project has no generated types, * or has types generated before this map existed — the fields that are always * right, rather than none at all. * * The key here MUST match the one `TypeGenerator` emits into `Config`. If the * two drift, this conditional silently takes the fallback branch and a `date` * field goes back to being typed as a string — no compile error anywhere, just * a row type that is wrong again. Pinned by * `__tests__/generated-config-contract.test.ts`. */ type DateFieldsOfCollectionFrom = TGenerated extends { collectionDateFields: infer D; } ? TSlug extends keyof D ? Extract : BuiltInDateField : BuiltInDateField; /** * The `Date`-backed field names of a single. * * Falls back to NOTHING rather than to the built-in timestamps, unlike * {@link DateFieldsOfCollectionFrom}. A single is read through a deserializer * that normalizes its system timestamps to ISO strings, so `updatedAt` is a * string here and naming it would be the one guess that is always wrong. Only * a single's own date fields are decoded, and those are known only from the * generated map. */ type DateFieldsOfSingleFrom = TGenerated extends { singleDateFields: infer D; } ? TSlug extends keyof D ? Extract : never : never; /** * Resolves the in-process document type for a collection slug — what the Direct * API hands back, as opposed to what the REST API serializes. * * Factored through a `From` generic for the same reason the field-group types * are: a test asserting against a locally re-declared copy of the conditional * would pass even when this alias reads the wrong key, which is the failure * being guarded. * * The document and its date fields are resolved inside ONE conditional on * `TSlug`, which distributes, so a union of slugs pairs each document with its * own date fields. Resolving the two separately and combining them afterwards * would union the date sets first, and a field one collection stores as text * would be typed `Date` because a different collection happens to store a date * under that name. * * @typeParam TSlug - The collection slug string literal * * @example * ```typescript * const post = await nextly.findByID({ collection: "posts", id }); * post?.createdAt.getTime(); // a Date in process * * type Wire = DataFromCollectionSlug<"posts">; * // Wire["createdAt"] is a string: the REST response is formatted text. * ``` */ type RowFromCollectionSlugFrom = TGenerated extends { collections: infer C; } ? TSlug extends keyof C ? InProcessRow> : Record : Record; type RowFromCollectionSlug = RowFromCollectionSlugFrom; /** * Resolves the in-process document type for a single slug. * * @typeParam TSlug - The single slug string literal */ type RowFromSingleSlugFrom = TGenerated extends { singles: infer C; } ? TSlug extends keyof C ? InProcessRow> : Record : Record; type RowFromSingleSlug = RowFromSingleSlugFrom; /** * User context for access control when `overrideAccess` is false. * * Carries the identity an access rule decides on. `id` and `role` are the * canonical fields; anything else you attach is passed through to the rule * untouched, so a `custom` rule can decide on a claim of your own (a tenant, a * plan, an entitlement) the same way it can over HTTP. */ interface UserContext$2 { /** Unique user identifier */ id: string; /** User's primary role for role-based access control */ role?: string; /** Full authorized role set, for rules that decide on more than one role. */ roles?: string[]; /** Any further claims your rules read. */ [claim: string]: unknown; } /** * Request context passed through to services and hooks. * * Contains information about the current request, user, and * provides access to the Direct API instance within hooks. */ interface RequestContext { /** Current user context (when authenticated) */ user?: UserContext$2; /** Custom context data passed to hooks */ context?: Record; /** Locale for localized content */ locale?: string; /** Fallback locale when requested locale data is missing */ fallbackLocale?: string | false; /** Transaction context for database operations */ transactionID?: string; } /** * Base configuration options shared across all Direct API operations. * * These options control access control, transactions, and response formatting. * * @example * ```typescript * // Bypass access control (default for Direct API) * await nextly.find({ collection: 'posts', overrideAccess: true }); * * // Enforce access control with user context * await nextly.find({ * collection: 'posts', * overrideAccess: false, * user: { id: 'user-123', role: 'editor' }, * }); * ``` */ interface DirectAPIConfig { /** * Bypass access control checks. * * When `true` (default for Direct API), all access control is skipped. * Set to `false` to enforce collection, field, and row-level permissions. * * @default true */ overrideAccess?: boolean; /** * Which collections `overrideAccess` may actually reach, asked per RELATED * collection as relationships are expanded. * * `overrideAccess: true` says the caller is trusted. It says nothing about * the collection a relationship points at — that one was never named here, * it was reached through a field — so a trusted read spreads its trust into * every target it populates, along with a widened lifecycle that includes * drafts. * * For a caller who has already decided who is asking, that is correct, and * omitting this keeps exactly that behaviour. A caller serving ONE fixed * audience is in the opposite position: it can state its trusted set up * front, and anything outside it must be read as that audience would read * it. A public route is the clearest case — it pre-renders, so a draft or * access-restricted row pulled in through a relationship is written to a * static artifact and outlives the row being unpublished. * * ```ts * // A blog route that populates authors, and trusts nothing else. * await nextly.find({ * collection: "posts", * overrideAccess: true, * trusted: name => name === "posts" || name === "authors", * }); * ``` * * **This can only ever narrow.** It is evaluated as * `overrideAccess && trusted(target)`, so supplying it removes trust the * caller already had and can never grant trust it did not — the same shape * as passing `overrideAccess: false`. A predicate rather than a list because * the question is asked once per target at several points, and membership is * often derivable rather than worth enumerating. * * @default undefined — every populated target inherits the caller's trust */ trusted?: (collection: string) => boolean; /** * User context for access control. * * Required when `overrideAccess` is `false`. Provides the user identity * and role for permission checks. */ user?: UserContext$2; /** * The authenticated caller's own scope, when it is an API key. * * Distinct from `user`, which says WHO the caller is. This says what KIND of * caller it is and which grants the key itself carries. A scoped key is * authorized on its own stamped permissions, not its owner's, so without this * an update-only key issued by a reader-plus-publisher is judged by the * owner's grants and reads what it was never given. * * Leave unset for session and system callers; they resolve grants normally. */ actor?: AuthenticatedScope; /** * Request context passed to hooks. * * Use this to pass custom data to hooks via `req.context`. */ req?: RequestContext; /** * Custom context data passed to hooks. * * This data is accessible in hooks via `req.context`. * Useful for passing request-specific information. */ context?: Record; /** * Include hidden fields in the response. * * Hidden fields (defined with `hidden: true` in field config) * are normally excluded from responses. Set to `true` to include them. * * @default false */ showHiddenFields?: boolean; /** * Return `null` instead of throwing errors for not-found scenarios. * * Applies to `findByID` and similar single-document operations. * When `true`, returns `null` if document not found. * When `false` (default), throws `NotFoundError`. * * @default false */ disableErrors?: boolean; /** * Skip database transaction wrapping. * * By default, write operations are wrapped in transactions. * Set to `true` to disable transaction wrapping. * * @default false */ disableTransaction?: boolean; /** * Locale for localized content. * * When set, returns content in the specified locale. */ locale?: string; /** * Fallback locale when requested locale data is missing. * * Set to `false` to disable fallback behavior. */ fallbackLocale?: string | false; /** * Relationship population depth. * * Controls how deeply to populate relationship and upload fields. * - `0`: No population (return IDs only) * - `1`: Populate direct relationships * - `2+`: Populate nested relationships * * @default 0 */ depth?: number; /** * Output format for rich text fields. * * Controls how rich text (Lexical JSON) fields are returned in responses. * - `"json"` (default): Return only the Lexical JSON structure * - `"html"`: Return only the HTML string * - `"both"`: Return an object with both `json` and `html` properties * * @default "json" * * @example * ```typescript * // Get rich text as both JSON and HTML * const posts = await nextly.find({ * collection: 'posts', * richTextFormat: 'both', * }); * // posts.items[0].content => { json: {...}, html: "

...

" } * * // Get rich text as HTML only * const posts = await nextly.find({ * collection: 'posts', * richTextFormat: 'html', * }); * // posts.items[0].content => "

...

" * ``` */ richTextFormat?: RichTextOutputFormat; /** * Ignore document locks. * * When `true` (default), operations proceed regardless of document locks. * Set to `false` to respect locks and fail if document is locked. * * @default true */ overrideLock?: boolean; /** * Forms API configuration. * * Override the default collection slugs used by the form builder plugin. * Only relevant when using the `nextly.forms.*` namespace. * * @example * ```typescript * const nextly = new Nextly({ * forms: { * collectionSlug: 'contact-forms', * submissionCollectionSlug: 'contact-responses', * }, * }); * ``` */ forms?: FormsConfig; } /** * Options for controlling relationship field population. * * Allows fine-grained control over which fields to populate * and how deeply to populate nested relationships. */ interface PopulateOptions { /** * Whether to populate this field. * * Set to `false` to skip population for this field. */ populate?: boolean; /** * Specific fields to select from the populated document. * * Use this to reduce response size by selecting only needed fields. */ select?: Record; /** * Maximum depth for nested relationship population. * * Overrides the global `depth` option for this specific field. */ depth?: number; } /** * Direct API Collection Type Definitions * * Argument and result types for collection CRUD, counting, and bulk operations. * * @packageDocumentation */ /** * Arguments for finding multiple documents in a collection. * * @typeParam TSlug - The collection slug literal type (auto-inferred from `collection`) * * @example * ```typescript * // With generated types - slug and return type are inferred: * const posts = await nextly.find({ collection: 'posts' }); * // posts.items is typed as Post[] * * // Without generated types - accepts any string: * const posts = await nextly.find({ * collection: 'posts', * where: { status: { equals: 'published' } }, * limit: 10, * sort: '-createdAt', * depth: 2, * }); * ``` */ interface FindArgs extends DirectAPIConfig { /** Collection slug (required) */ collection: TSlug; /** * Query conditions for filtering. * * Where clause syntax for filtering. * * @example * ```typescript * where: { * status: { equals: 'published' }, * publishedAt: { less_than: new Date().toISOString() }, * } * ``` */ where?: WhereFilter; /** * Draft/Published lifecycle scope for the read (only effective when the * collection has the built-in `status` lifecycle). Unlike a `where` clause on * the `status` column, this drives the query service's lifecycle-aware filter, * so it ALSO constrains a localized collection's per-locale companion * `_status` — a draft translation under a published main row is not returned. * `"published"` is enforced even for a trusted (`overrideAccess: true`) read. */ status?: "published" | "draft" | "all"; /** * Maximum documents per page. * * @default 10 */ limit?: number; /** * Page number (1-indexed). * * @default 1 */ page?: number; /** * Sort order. * * Use field name for ascending, prefix with `-` for descending. * * @example * ```typescript * sort: '-createdAt' // Newest first * sort: 'title' // Alphabetical * ``` */ sort?: string; /** * Specific fields to include/exclude. * * Set field to `true` to include, `false` to exclude. * By default, all non-hidden fields are included. * * @example * ```typescript * select: { title: true, content: true, author: true } * ``` */ select?: Record; /** * Control relationship population per field. * * @example * ```typescript * populate: { * author: { select: { name: true, email: true } }, * category: false, // Don't populate * } * ``` */ populate?: Record; /** * Disable pagination and return all documents. * * When `false`, returns all matching documents without pagination metadata. * Use with caution for large collections. * * @default true */ pagination?: boolean; } /** * Arguments for finding a single document by ID. * * @example * ```typescript * const post = await nextly.findByID({ * collection: 'posts', * id: 'post-123', * depth: 2, * }); * ``` */ interface FindByIDArgs extends DirectAPIConfig { /** Collection slug (required) */ collection: TSlug; /** Document ID (required) */ id: string; /** * Return the pending working draft in place of the live row when one exists * (draft/published split). Mirrors the read-side `draft` parameter in * Payload's `findByID`. Effective only on a drafts-enabled, non-localized * collection with the `status` lifecycle, and gated by an update-capability * probe: a caller who cannot edit the document still gets the published row, * so this never exposes a draft to a read-only caller. * * @default false */ draft?: boolean; /** * Specific fields to include/exclude. */ select?: Record; /** * Control relationship population per field. */ populate?: Record; } /** * Arguments for creating a new document. * * @typeParam TSlug - The collection slug literal type (auto-inferred from `collection`) * * @example * ```typescript * const post = await nextly.create({ * collection: 'posts', * data: { * title: 'Hello World', * content: 'My first post', * status: 'draft', * }, * }); * ``` */ interface CreateArgs extends DirectAPIConfig { /** Collection slug (required) */ collection: TSlug; /** Document data (required) */ data: Record; /** * ID of existing document to duplicate. * * When provided, copies data from the source document * and merges with provided `data`. */ duplicateFromID?: string; /** * Skip validation hooks. * * @default false */ draft?: boolean; /** * Disable verification email for auth collections. * * When creating users in auth-enabled collections, * set to `true` to skip sending verification email. * * @default false */ disableVerificationEmail?: boolean; /** * Skip cache revalidation for this write (the outbox drain still runs, so * webhooks are unaffected). Set by a CLI, seed, or bulk-import caller that * owns its own cache strategy and does not want a revalidation per row. * * @default false */ disableRevalidate?: boolean; } /** * Arguments for updating an existing document. * * Supports updating by ID or by where clause (bulk update). * * @typeParam TSlug - The collection slug literal type (auto-inferred from `collection`) * * @example * ```typescript * // Update by ID * await nextly.update({ * collection: 'posts', * id: 'post-123', * data: { status: 'published' }, * }); * * // Bulk update by where clause * await nextly.update({ * collection: 'posts', * where: { status: { equals: 'draft' } }, * data: { status: 'archived' }, * }); * ``` */ interface UpdateArgs extends DirectAPIConfig { /** Collection slug (required) */ collection: TSlug; /** * Document ID for single update. * * Either `id` or `where` must be provided. */ id?: string; /** * Query conditions for bulk update. * * Either `id` or `where` must be provided. */ where?: WhereFilter; /** Update data (required) */ data: Record; /** * Autosave draft instead of publishing. * * @default false */ draft?: boolean; /** * Overwrite existing files instead of creating new versions. * * Applies to upload collections. * * @default false */ overwriteExistingFiles?: boolean; /** * Skip cache revalidation for this write (the outbox drain still runs). Set by * a CLI, seed, or bulk-import caller that owns its own cache strategy. * * @default false */ disableRevalidate?: boolean; } /** * Arguments for deleting documents. * * Supports deleting by ID or by where clause (bulk delete). * * @example * ```typescript * // Delete by ID * await nextly.delete({ * collection: 'posts', * id: 'post-123', * }); * * // Bulk delete by where clause * await nextly.delete({ * collection: 'posts', * where: { status: { equals: 'archived' } }, * }); * ``` */ interface DeleteArgs extends DirectAPIConfig { /** Collection slug (required) */ collection: TSlug; /** * Document ID for single delete. * * Either `id` or `where` must be provided. */ id?: string; /** * Query conditions for bulk delete. * * Either `id` or `where` must be provided. */ where?: WhereFilter; /** * Skip cache revalidation for this delete (the outbox drain still runs). Set * by a CLI, seed, or bulk-import caller that owns its own cache strategy. * * @default false */ disableRevalidate?: boolean; } /** * Arguments for counting documents in a collection. * * @example * ```typescript * const { total } = await nextly.count({ * collection: 'posts', * where: { status: { equals: 'published' } }, * }); * ``` */ interface CountArgs extends DirectAPIConfig { /** Collection slug (required) */ collection: TSlug; /** Query conditions for filtering */ where?: WhereFilter; } /** * Arguments for bulk deleting multiple documents by IDs. * * @example * ```typescript * const result = await nextly.bulkDelete({ * collection: 'posts', * ids: ['post-1', 'post-2', 'post-3'], * }); * ``` */ interface BulkDeleteArgs extends DirectAPIConfig { /** Collection slug (required) */ collection: TSlug; /** Array of document IDs to delete (required) */ ids: string[]; /** * Skip cache revalidation for this bulk delete (the outbox drain still runs). * Set by a CLI, seed, or bulk-import caller that owns its cache strategy. * * @default false */ disableRevalidate?: boolean; } /** * Arguments for duplicating a document. * * @example * ```typescript * const duplicate = await nextly.duplicate({ * collection: 'posts', * id: 'post-123', * overrides: { title: 'Copy of Original' }, * }); * ``` */ interface DuplicateArgs extends DirectAPIConfig { /** Collection slug (required) */ collection: TSlug; /** ID of document to duplicate (required) */ id: string; /** * Field overrides to apply to the duplicate. * * These values override the copied data. */ overrides?: Record; /** * Skip cache revalidation for this duplicate (the outbox drain still runs). * Set by a CLI, seed, or bulk-import caller that owns its cache strategy. * * @default false */ disableRevalidate?: boolean; } /** * Result of a count operation. * * the wire API's `respondCount` envelope both speak the same key. */ interface CountResult { /** Total number of documents matching the query */ total: number; } /** * Result of a delete-by-id or delete-by-where operation. * * `delete()` calls return `{ message, item }` (`MutationResult`) so they * match the wire API's `respondMutation` envelope. `DeleteResult` is still * used for the bulk-by-where path where multiple IDs may be returned. */ interface DeleteResult { /** Whether the delete was successful */ deleted: boolean; /** IDs of deleted documents */ ids: string[]; /** * Side effects that failed after the rows were deleted, when any did. * * Present only when a post-commit hook threw. The rows are gone either way, * so this reports a side effect that did not run rather than a failed * delete. Mirrors `MutationResult.warnings`, so a delete by `where` reports * a hook failure the same way a delete by id does. */ warnings?: HookWarning[]; } /** * Result of a bulk operation with partial success support. * * Phase 4.5: redesigned to carry full success records (not just ids) and * structured per-item failures keyed by canonical NextlyErrorCode. The * direct-API surface mirrors the wire shape emitted by respondBulk so * direct-API callers and HTTP callers see the same data on the success * path. * * Generic over T: * - For delete: T is `{ id: string }`. * - For update/create: T is the full record. */ interface BulkOperationResult$2 { /** Records successfully processed. */ successes: T[]; /** Structured per-item failures. */ failures: Array<{ /** Identifier of the entry that failed. */ id: string; /** Canonical NextlyErrorCode value. */ code: string; /** Public-safe message (no identifier or value echo). */ message: string; }>; /** Total number of documents processed. */ total: number; /** Number of successful operations. */ successCount: number; /** Number of failed operations. */ failedCount: number; /** * Side effects that failed after the rows were written, when any did. * * Distinct from `failures`, which is per-ITEM and means that item did not * happen. This is per-OPERATION: every listed success is durable, and a hook * that ran after the write threw. Reporting one as the other would tell a * caller a saved row failed and invite a retry that writes it twice. */ warnings?: HookWarning[]; } /** * Direct API Singles Type Definitions * * Argument and result types for single (global) CRUD, metadata listing, * and related operations. * * @packageDocumentation */ /** * Arguments for retrieving a single document. * * @example * ```typescript * const settings = await nextly.findSingle({ * slug: 'site-settings', * depth: 1, * }); * ``` */ interface FindSingleArgs extends DirectAPIConfig { /** Single slug (required) */ slug: TSlug; /** * Specific fields to include/exclude. */ select?: Record; /** * Control relationship population per field. */ populate?: Record; } /** * Arguments for updating a single document. * * @typeParam TSlug - The single slug literal type (auto-inferred from `slug`) * * @example * ```typescript * await nextly.updateSingle({ * slug: 'site-settings', * data: { * siteName: 'My Site', * maintenanceMode: false, * }, * }); * ``` */ interface UpdateSingleArgs extends DirectAPIConfig { /** Single slug (required) */ slug: TSlug; /** Update data (required) */ data: Record; /** * Autosave draft instead of publishing. * * @default false */ draft?: boolean; /** * Skip cache revalidation for this write (the outbox drain still runs). Set by * a CLI, seed, or bulk-import caller that owns its own cache strategy. * * @default false */ disableRevalidate?: boolean; } /** * Arguments for listing the actual content of all registered Single types. * * @example * ```typescript * // Fetch content for all registered Singles * const result = await nextly.findSingles(); * result.docs.forEach(({ slug, data }) => console.log(slug, data)); * * // Filter by source * const codeSingles = await nextly.findSingles({ source: 'code' }); * * // Search by name * const settingsSingles = await nextly.findSingles({ search: 'settings' }); * ``` */ interface FindSinglesArgs extends DirectAPIConfig { /** Filter by source type */ source?: "code" | "ui" | "built-in"; /** Filter by migration status */ migrationStatus?: "synced" | "pending" | "generated" | "applied" | "failed"; /** Include only locked or unlocked Singles */ locked?: boolean; /** Search query for filtering by slug or label */ search?: string; /** Maximum number of results */ limit?: number; /** Number of results to skip (for pagination) */ offset?: number; } /** * A single entry returned by `findSingles`, pairing the slug with the * actual document content of that Single type. */ interface SingleEntry { /** The single slug (e.g., 'site-settings') */ slug: string; /** The display label/title for this Single */ label: string; /** The actual document content */ data: Record; } /** * Result of `findSingles` — the actual content for each matching Single type. */ interface SingleListResult { /** Single entries with actual document content */ docs: SingleEntry[]; /** Total count of matching Singles (before pagination) */ totalDocs: number; /** Number of results returned */ limit: number; /** Number of results skipped */ offset: number; } /** * Arguments for logging in a user. * * @example * ```typescript * const { user, token } = await nextly.login({ * collection: 'users', * email: 'user@example.com', * password: 'secure-password', * }); * ``` */ interface LoginArgs { /** User collection slug (defaults to 'users') */ collection?: string; /** User email (required) */ email: string; /** User password (required) */ password: string; } /** * Arguments for registering a new user. * * @example * ```typescript * const { user, token } = await nextly.register({ * collection: 'users', * email: 'newuser@example.com', * password: 'secure-password', * name: 'New User', * }); * ``` */ interface RegisterArgs { /** User collection slug (defaults to 'users') */ collection?: string; /** User email (required) */ email: string; /** User password (required) */ password: string; /** Additional user data */ [key: string]: unknown; } /** * Arguments for changing a user's password. */ interface ChangePasswordArgs { /** User collection slug (defaults to 'users') */ collection?: string; /** Current password (required) */ currentPassword: string; /** New password (required) */ newPassword: string; } /** * Arguments for initiating password reset. */ interface ForgotPasswordArgs { /** User collection slug (defaults to 'users') */ collection?: string; /** User email (required) */ email: string; /** * Disable sending the password reset email. * * When `true`, returns the reset token without sending email. * Useful for custom email handling. * * @default false */ disableEmail?: boolean; /** * Token expiration time in seconds. * * @default 3600 (1 hour) */ expiration?: number; /** * Custom path for the password reset page link in the email. * Must be a relative path starting with `/`. * The full URL is constructed as `{baseUrl}{redirectPath}?token=...`. * * Overrides `emailConfig.resetPasswordPath` for this request. * * @default '/admin/reset-password' (or value from EmailConfig.resetPasswordPath) * @example '/auth/reset-password' */ redirectPath?: string; } /** * Arguments for resetting password with token. */ interface ResetPasswordArgs { /** User collection slug (defaults to 'users') */ collection?: string; /** Password reset token (required) */ token: string; /** New password (required) */ password: string; } /** * Arguments for verifying user email. */ interface VerifyEmailArgs { /** User collection slug (defaults to 'users') */ collection?: string; /** Email verification token (required) */ token: string; } /** * Result of a login operation. */ interface LoginResult { /** Authenticated user object */ user: Record; /** JWT token for subsequent requests */ token: string; /** Token expiration timestamp */ exp: number; } /** * Result of an auth check. */ interface AuthResult { /** Current user (null if not authenticated) */ user: Record | null; /** User's permissions */ permissions?: Record; } /** * Direct API Users Type Definitions * * Argument types for the `nextly.users.*` namespace. * * @packageDocumentation */ /** * Arguments for finding users. * * Extends FindArgs with user-specific filter and sort options. * * @example * ```typescript * // List all verified users, newest first * const result = await nextly.users.find({ * emailVerified: true, * sortBy: 'createdAt', * sortOrder: 'desc', * limit: 20, * }); * * // Search by name or email * const result = await nextly.users.find({ search: 'john' }); * ``` */ /** * The `FindArgs` options the users namespace actually forwards. * * Users are a core table with their own query service rather than a dynamic * collection, and `users.find()` passes only pagination through to it. Anything * else inherited from `FindArgs` was accepted and silently discarded: a `where` * clause filtered nothing and returned the first arbitrary user, which reads at * the call site as a successful exact lookup. * * Named as an ALLOW-list rather than as an omission, so the default for anything * new is refusal. A deny-list re-opens itself the moment `FindArgs` gains an * option — the new one would be inherited, ignored at runtime, and produce a * plausible wrong row with no compile error, which is the same failure this * type is here to prevent. * * To support one of the others, forward it in `namespaces/users.ts` and add it * here in the same change. */ type ForwardedFindOptions = "limit" | "page"; interface FindUsersArgs extends Pick, DirectAPIConfig { /** User collection slug (defaults to 'users') */ collection?: string; /** Search query across name, email, and custom text fields */ search?: string; /** Filter by email verification status */ emailVerified?: boolean; /** Filter by whether user has a password set */ hasPassword?: boolean; /** Sort field */ sortBy?: "createdAt" | "name" | "email"; /** Sort direction */ sortOrder?: "asc" | "desc"; } /** * Arguments for finding a single user by criteria. * * Returns the first user matching the provided filters, or `null` if not found. * Consistent with collection `findByID` semantics — use `users.findByID` when * you already have the user ID; use `findOne` when querying by other attributes. * * @example * ```typescript * // Find by email (exact match via search) * const user = await nextly.users.findOne({ search: 'john@example.com' }); * * // Find first unverified user * const unverified = await nextly.users.findOne({ emailVerified: false }); * ``` */ interface FindOneUserArgs extends DirectAPIConfig { /** User collection slug (defaults to 'users') */ collection?: string; /** Search query across name, email, and custom text fields */ search?: string; /** Filter by email verification status */ emailVerified?: boolean; /** Filter by whether user has a password set */ hasPassword?: boolean; } /** * Arguments for finding a user by ID. * * `draft` is omitted from the shared find-by-ID options: the working-draft * overlay applies to drafts-enabled content collections, and the users * namespace does not forward it, so exposing it here would advertise an option * that is silently ignored. */ interface FindUserByIDArgs extends Omit { /** User collection slug (defaults to 'users') */ collection?: string; } /** * Arguments for creating a user. */ interface CreateUserArgs extends Omit { /** User collection slug (defaults to 'users') */ collection?: string; /** User email (required) */ email: string; /** User password (required) */ password: string; } /** * Arguments for updating a user. */ interface UpdateUserArgs extends Omit { /** User collection slug (defaults to 'users') */ collection?: string; } /** * Arguments for deleting a user. */ interface DeleteUserArgs extends Omit { /** User collection slug (defaults to 'users') */ collection?: string; } /** * Direct API Media Type Definitions * * Argument types for upload, find, update, delete operations on media files * and media folders. * * @packageDocumentation */ /** * File data for upload operations. * * Represents a file to be uploaded to the media library. */ interface UploadFileData { /** File content as Buffer */ data: Buffer; /** Original filename (e.g., 'photo.jpg') */ name: string; /** MIME type (e.g., 'image/jpeg', 'video/mp4') */ mimetype: string; /** File size in bytes */ size: number; } /** * Arguments for uploading a media file. * * @example * ```typescript * import fs from 'fs'; * * const buffer = fs.readFileSync('./image.png'); * const media = await nextly.media.upload({ * file: { * data: buffer, * name: 'image.png', * mimetype: 'image/png', * size: buffer.length, * }, * altText: 'My image', * folder: 'uploads', * }); * ``` */ interface UploadMediaArgs extends DirectAPIConfig { /** File to upload (required) */ file: UploadFileData; /** Alternative text for accessibility */ altText?: string; /** Folder ID to upload into (defaults to root) */ folder?: string; } /** * Arguments for finding media files. * * @example * ```typescript * const images = await nextly.media.find({ * folder: 'folder-id', * mimeType: 'image', * limit: 20, * }); * ``` */ interface FindMediaArgs extends DirectAPIConfig { /** Filter by folder ID */ folder?: string; /** Filter by media type ('image', 'video', 'audio', 'document', 'other') */ mimeType?: string; /** Search query (filename, altText) */ search?: string; /** Maximum files per page */ limit?: number; /** Page number (1-indexed) */ page?: number; /** Sort field */ sortBy?: "uploadedAt" | "filename" | "size"; /** Sort direction */ sortOrder?: "asc" | "desc"; } /** * Arguments for finding a media file by ID. * * @example * ```typescript * const media = await nextly.media.findByID({ id: 'media-123' }); * ``` */ interface FindMediaByIDArgs extends DirectAPIConfig { /** Media file ID (required) */ id: string; } /** * Arguments for updating media metadata. * * @example * ```typescript * const updated = await nextly.media.update({ * id: 'media-123', * data: { altText: 'Updated alt text', tags: ['photo', 'nature'] }, * }); * ``` */ interface UpdateMediaArgs extends DirectAPIConfig { /** Media file ID (required) */ id: string; /** Update data */ data: { /** Updated filename */ filename?: string; /** Updated alt text */ altText?: string | null; /** Updated caption */ caption?: string | null; /** Updated tags */ tags?: string[]; /** Move to folder (null for root) */ folderId?: string | null; }; } /** * Arguments for deleting a media file. * * @example * ```typescript * await nextly.media.delete({ id: 'media-123' }); * ``` */ interface DeleteMediaArgs extends DirectAPIConfig { /** Media file ID (required) */ id: string; } /** * Arguments for bulk deleting media files. * * @example * ```typescript * const result = await nextly.media.bulkDelete({ * ids: ['media-1', 'media-2', 'media-3'], * }); * ``` */ interface BulkDeleteMediaArgs extends DirectAPIConfig { /** Array of media file IDs to delete (required) */ ids: string[]; } /** * Arguments for listing media folders. * * @example * ```typescript * // List root folders * const rootFolders = await nextly.media.folders.list(); * * // List subfolders * const subfolders = await nextly.media.folders.list({ parent: 'folder-id' }); * ``` */ interface ListFoldersArgs extends DirectAPIConfig { /** Parent folder ID (defaults to root if not specified) */ parent?: string; } /** * Arguments for creating a media folder. * * @example * ```typescript * const folder = await nextly.media.folders.create({ * name: 'Photos', * description: 'Photo uploads', * parent: 'parent-folder-id', * }); * ``` */ interface CreateFolderArgs extends DirectAPIConfig { /** Folder name (required) */ name: string; /** Description of the folder */ description?: string; /** Folder color (for UI) */ color?: string; /** Folder icon (for UI) */ icon?: string; /** Parent folder ID (defaults to root) */ parent?: string; } /** * Checkbox Field Type * * A boolean toggle field that stores true/false values. * Renders as a checkbox input in the Admin UI. * * @module collections/fields/types/checkbox * @since 1.0.0 */ /** * Possible value types for a checkbox field. * * - `boolean` - true or false * - `null` - Explicitly empty value * - `undefined` - Value not set */ type CheckboxFieldValue = boolean | null | undefined; /** * Admin panel options specific to checkbox fields. * * Checkbox fields use the base admin options without additional * field-specific settings. */ type CheckboxFieldAdminOptions = FieldAdminOptions; /** * Configuration interface for checkbox fields. * * Checkbox fields store boolean values (true/false) and are * commonly used for toggles, flags, and binary choices. * * **Use Cases:** * - Feature toggles (enabled/disabled) * - Agreement checkboxes (terms accepted) * - Visibility flags (published, featured, archived) * - Binary preferences (notifications enabled) * * @example * ```typescript * // Basic checkbox field * const publishedField: CheckboxFieldConfig = { * name: 'published', * type: 'checkbox', * label: 'Published', * defaultValue: false, * }; * * // Required checkbox (e.g., terms acceptance) * const termsField: CheckboxFieldConfig = { * name: 'termsAccepted', * type: 'checkbox', * label: 'I accept the terms and conditions', * required: true, * validate: (value) => { * if (value !== true) { * return 'You must accept the terms to continue'; * } * return true; * }, * }; * * // Feature toggle with admin description * const featuredField: CheckboxFieldConfig = { * name: 'featured', * type: 'checkbox', * label: 'Featured', * defaultValue: false, * admin: { * description: 'Display this item in the featured section', * position: 'sidebar', * }, * }; * * // Conditional checkbox * const sendNotificationsField: CheckboxFieldConfig = { * name: 'sendNotifications', * type: 'checkbox', * label: 'Send email notifications', * defaultValue: true, * admin: { * condition: { * field: 'email', * exists: true, * }, * }, * }; * ``` */ interface CheckboxFieldConfig extends Omit { /** * Field type identifier. */ type: "checkbox"; /** * Default value for the field. * * Can be a static boolean or a function that returns a boolean. * If not specified and `required: true`, defaults to `false`. * * @example * ```typescript * // Static default * defaultValue: false * * // Dynamic default based on other data * defaultValue: (data) => data.role === 'admin' * ``` */ defaultValue?: boolean | ((data: Record) => boolean); /** * Admin UI configuration options. */ admin?: CheckboxFieldAdminOptions; /** * Custom validation function. * * Receives the typed boolean value and returns `true` for valid * or an error message string for invalid. * * @param value - The checkbox field value (boolean, null, or undefined) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Require true value (e.g., terms acceptance) * validate: (value) => { * if (value !== true) { * return 'This field must be checked'; * } * return true; * } * * // Conditional validation * validate: (value, { data }) => { * if (data.type === 'premium' && value !== true) { * return 'Premium items must be featured'; * } * return true; * } * ``` */ validate?: (value: CheckboxFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Chips Field Type * * A free-form multi-value string field that stores an array of strings. * Renders as interactive chips/tags in the Admin UI. * * @module collections/fields/types/chips * @since 1.0.0 */ /** * Possible value types for a chips field. */ type ChipsFieldValue = string[] | null | undefined; /** * Admin panel options specific to chips fields. */ interface ChipsFieldAdminOptions extends FieldAdminOptions { /** * Placeholder text for the chip input. * @default 'Type and press Enter to add' */ placeholder?: string; } /** * Configuration interface for chips fields. * * Chips fields store an array of unique free-form strings. * Renders as interactive chips/tags with add/remove capability. * Duplicate values are automatically prevented. * * @example * ```typescript * // Basic chips field * chips({ name: 'tags', label: 'Tags' }) * * // With max limit * chips({ name: 'keywords', label: 'Keywords', maxChips: 10 }) * * // Required with minimum * chips({ name: 'categories', required: true, minChips: 1, maxChips: 5 }) * ``` */ interface ChipsFieldConfig extends Omit { /** * Field type identifier. Must be 'chips'. */ type: "chips"; /** * Default value for the field. */ defaultValue?: string[] | ((data: Record) => string[]); /** * Maximum number of chips allowed. * When reached, the add input is hidden. */ maxChips?: number; /** * Minimum number of chips required (used for validation). */ minChips?: number; /** * Admin UI configuration options. */ admin?: ChipsFieldAdminOptions; /** * Custom validation function. */ validate?: (value: ChipsFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Code Field Type * * A specialized text field for code input with syntax highlighting. * Renders as a code editor in the Admin UI with language-specific * highlighting and formatting. * * @module collections/fields/types/code * @since 1.0.0 */ /** * Supported programming languages for syntax highlighting. * * These languages are supported by the code editor for * syntax highlighting and code formatting. */ type CodeLanguage = "javascript" | "typescript" | "jsx" | "tsx" | "html" | "css" | "scss" | "less" | "json" | "markdown" | "yaml" | "xml" | "sql" | "graphql" | "python" | "ruby" | "php" | "java" | "c" | "cpp" | "csharp" | "go" | "rust" | "swift" | "kotlin" | "shell" | "bash" | "powershell" | "dockerfile" | "plaintext"; /** * Possible value types for a code field. */ type CodeFieldValue = string | null | undefined; /** * Code editor configuration options. * * These options control the behavior and appearance of the * code editor in the Admin UI. */ interface CodeEditorOptions { /** * Show line numbers in the editor. * * @default true */ lineNumbers?: boolean; /** * Enable word wrapping. * * @default false */ wordWrap?: boolean; /** * Tab size in spaces. * * @default 2 */ tabSize?: number; /** * Use tabs instead of spaces for indentation. * * @default false */ useTabs?: boolean; /** * Minimum height of the editor in pixels. * * @default 200 */ minHeight?: number; /** * Maximum height of the editor in pixels. * * When set, the editor will scroll if content exceeds this height. */ maxHeight?: number; /** * Font size in pixels. * * @default 14 */ fontSize?: number; /** * Font family for the code editor. * * @default 'monospace' */ fontFamily?: string; /** * Enable code folding. * * @default true */ folding?: boolean; /** * Enable bracket matching highlight. * * @default true */ matchBrackets?: boolean; /** * Enable auto-closing of brackets and quotes. * * @default true */ autoCloseBrackets?: boolean; } /** * Admin panel options specific to code fields. * * Extends the base admin options with code editor settings. */ interface CodeFieldAdminOptions extends FieldAdminOptions { /** * Programming language for syntax highlighting. * * If not specified, defaults to 'plaintext' (no highlighting). */ language?: CodeLanguage; /** * Code editor configuration options. */ editorOptions?: CodeEditorOptions; } /** * Configuration interface for code fields. * * Code fields provide a full-featured code editor with syntax * highlighting, line numbers, and other IDE-like features. * They are ideal for storing code snippets, configuration files, * or any structured text content. * * @example * ```typescript * // Basic code field * const snippetField: CodeFieldConfig = { * name: 'snippet', * type: 'code', * label: 'Code Snippet', * admin: { * language: 'javascript', * }, * }; * * // JSON configuration field * const configField: CodeFieldConfig = { * name: 'config', * type: 'code', * label: 'Configuration', * admin: { * language: 'json', * description: 'Enter valid JSON configuration', * editorOptions: { * lineNumbers: true, * minHeight: 300, * }, * }, * validate: (value) => { * if (value) { * try { * JSON.parse(value); * } catch { * return 'Invalid JSON format'; * } * } * return true; * }, * }; * * // CSS styles field * const customCssField: CodeFieldConfig = { * name: 'customCss', * type: 'code', * label: 'Custom CSS', * admin: { * language: 'css', * editorOptions: { * wordWrap: true, * minHeight: 200, * maxHeight: 500, * }, * }, * }; * * // Multi-language code field (user selects language) * const codeBlockField: CodeFieldConfig = { * name: 'codeBlock', * type: 'code', * label: 'Code Block', * admin: { * language: 'plaintext', // Default, can be changed * editorOptions: { * lineNumbers: true, * folding: true, * }, * }, * }; * ``` */ interface CodeFieldConfig extends Omit { /** * Field type identifier. Must be 'code'. */ type: "code"; /** * Default value for the field. * * Can be a static string or a function that returns one. */ defaultValue?: string | ((data: Record) => string); /** * Admin UI configuration options including language and editor settings. */ admin?: CodeFieldAdminOptions; /** * Nested validation knobs. Mirrors the Schema Builder shape so code-first * config and the Builder UI converge on one source of truth. Pattern * validation (regex) on code fields runs through the same Zod pipeline as * text and textarea. */ validation?: FieldValidation; /** * Custom validation function. * * Use this to validate the code content, such as checking * for valid JSON, XML, or custom syntax rules. * * @param value - The code field value (string, null, or undefined) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Validate YAML syntax * validate: (value) => { * if (value) { * try { * yaml.parse(value); * } catch (e) { * return `Invalid YAML: ${e.message}`; * } * } * return true; * } * ``` */ validate?: (value: CodeFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * The on-disk spellings for component (field group) storage. * * Every value here describes data that already exists in deployed databases: * table names, the prefix generated names carry, the system columns embedded * instances are keyed by, and the discriminators written into stored JSON. * Code that creates, addresses, or migrates that storage reads these constants * instead of spelling the identifier inline, so the wording cannot drift * between the path that writes a table and the path that later reads it. * * Changing a value here is not an edit — it is a schema migration for every * existing database, and only the migration engine may make one. Renaming the * concept in the public API does NOT change these; the vocabulary users see and * the vocabulary on disk are separate contracts, and they move at different * times for exactly that reason. * * @module schemas/storage-format */ declare const STORAGE_FORMAT: { /** Registry table holding one row per component, including its table name. */ readonly registryTable: "dynamic_components"; /** Prefix every generated component table name carries. */ readonly tablePrefix: "comp_"; /** * Suffix identifying a localization companion of a main table. * * Shared with collection and single tables, like the index prefixes below: * the localization layer applies it to every entity kind, so it names no * concept and does not move when the concept is renamed. */ readonly companionSuffix: "_locales"; /** * System columns on a component data table. Embedded instances carry no * foreign key back to their parent; these three string columns plus the * ordinal are the whole association, which is why nothing cascades and the * teardown sweep has to walk them explicitly. */ readonly columns: { readonly parentId: "_parent_id"; readonly parentTable: "_parent_table"; readonly parentField: "_parent_field"; readonly order: "_order"; /** Type discriminator, written only for dynamic-zone rows. */ readonly type: "_component_type"; }; /** * Prefixes for generated index and unique-index names. * * Shared with collection and single tables rather than owned by components, * so they name no concept and do not move when the concept is renamed. They * live here because they are still on-disk spellings: changing one renames * an index in every existing database. */ readonly indexPrefix: "idx_"; readonly uniqueIndexPrefix: "uq_"; /** * Columns of the one index every component data table carries, in index order. * * ORDERED deliberately: this arrangement is what serves a lookup by parent id, and a differently * ordered index over the same three columns does not. Stated once here because two very different * consumers need the same answer — the DDL that CREATES the index, and any check asking whether a * live table still has it — and a check that restated the list would agree until one side moved. * * Deliberately NOT the whole set of indexes a component table can carry: per-field indexes come * from the field definitions, and the migrate snapshot builder additionally models a `created_at` * index that the table-creating DDL does not emit. This is only the structural one. */ readonly parentIndexColumns: readonly ["_parent_id", "_parent_table", "_parent_field"]; /** Directory segment recorded in a registry row's `config_path`. */ readonly configPathDir: "components"; /** * Discriminator written into a stored field definition's `type`. * * Persisted inside the `fields` JSON of every collection, single and * component registry row, and inside `ui-schema.json`, so it is on-disk data * rather than an in-memory tag. */ readonly fieldType: "component"; /** * Discriminator naming which component a dynamic-zone instance is. * * Distinct from `columns.type`: that is the database column, this is the key * the same value travels under once it is JSON — entry reads, version * snapshots, webhook envelopes, generated types and query filters. The two * spell the concept differently and are migrated separately. */ readonly wireTypeKey: "_componentType"; /** * Property names a stored field definition uses to reference components. * * `single` embeds one named component; `many` is the whitelist a dynamic * zone accepts. `legacy` predates both and is still read from rows written * by older versions, so it is a read-only compatibility spelling — nothing * writes it. */ readonly refKeys: { readonly single: "component"; readonly many: "components"; readonly legacy: "componentSlug"; }; /** * The `ui-schema.json` manifest contract. * * `key` is the top-level array of component definitions and `entityKind` is * how one entity announces itself inside that file. Both are read by the * Schema Builder and by every CLI path that diffs a manifest, so they change * only in lockstep with the file's `version`. */ readonly manifest: { readonly key: "components"; readonly entityKind: "component"; readonly version: 1; readonly schemaUrl: "https://nextlyhq.com/schemas/ui-schema.v1.json"; }; /** Scope kind a schema event carries when it concerns a component. */ readonly schemaEventScope: "component"; }; /** * Component Field Type * * Defines the component field configuration for embedding Components * (reusable field groups) within Collections, Singles, or other Components. * * Component fields support three embedding modes: * - **Single component:** Embed one specific component type (like a typed group) * - **Multi-component (dynamic zone):** Allow editors to pick from multiple types * - **Repeatable:** Either mode can be repeated as an array of instances * * Components support nesting: a component's fields can include component * fields referencing other components (max depth: 3 levels). * * @module collections/fields/types/component * @since 1.0.0 */ /** * Component field configuration. * * Embeds a component (or selection of components) within a Collection, * Single, or another Component. * * **Modes:** * - `component: 'seo'` — embeds one specific component type (single mode) * - `components: ['hero', 'cta']` — dynamic zone, editor picks type (multi mode) * * Each mode supports `repeatable: true` for arrays of component instances. * * @example Single component mode * ```typescript * import { fieldGroup } from 'nextly'; * * // Embed one specific component type * fieldGroup({ * name: 'seo', * component: 'seo', * }) * ``` * * @example Multi-component mode (dynamic zone) * ```typescript * import { fieldGroup } from 'nextly'; * * // Allow editors to pick from multiple component types * fieldGroup({ * name: 'layout', * components: ['hero', 'cta', 'content'], * repeatable: true, * }) * ``` * * @example Repeatable single component * ```typescript * import { fieldGroup } from 'nextly'; * * // Array of the same component type * fieldGroup({ * name: 'features', * component: 'feature-card', * repeatable: true, * minRows: 1, * maxRows: 12, * }) * ``` */ interface FieldGroupFieldConfig extends BaseFieldConfig { type: typeof STORAGE_FORMAT.fieldType; /** * Single component mode: embed one specific component type. * Mutually exclusive with `components`. * * @example 'seo' */ component?: string; /** * Multi-component mode (dynamic zone): allow editor to pick from * multiple component types. * Mutually exclusive with `component`. * * @example ['hero', 'cta', 'content', 'image-gallery'] */ components?: string[]; /** * Whether this field allows multiple instances (array). * - `false`: single instance (like group) * - `true`: repeatable array of instances (like array) * * @default false */ repeatable?: boolean; /** * Minimum number of instances (when `repeatable: true`). */ minRows?: number; /** * Maximum number of instances (when `repeatable: true`). */ maxRows?: number; /** * Admin UI options for the component field. */ admin?: BaseFieldConfig["admin"] & { /** * Whether component instances start collapsed in the form. * @default false */ initCollapsed?: boolean; /** * Whether instances can be reordered via drag-and-drop. * Only applies when `repeatable: true`. * @default true */ isSortable?: boolean; }; } /** * Date Field Type * * A date/time picker field that stores date values. * Supports various picker appearances (day only, day and time, time only, month only). * Dates are stored in UTC format in the database. * * @module collections/fields/types/date * @since 1.0.0 */ /** * Possible value types for a date field. * * - `string` - ISO 8601 date string (stored in UTC) * - `Date` - JavaScript Date object (converted to ISO string for storage) * - `null` - Explicitly empty value * - `undefined` - Value not set */ type DateFieldValue = string | Date | null | undefined; /** * Date picker appearance options. * * Controls what the user can select in the date picker. */ type DatePickerAppearance = "dayOnly" | "dayAndTime" | "timeOnly" | "monthOnly"; /** * Date-specific options for the date picker. */ interface DatePickerOptions { /** * Date picker appearance style. * * - `'dayOnly'` - Date selection only (default) * - `'dayAndTime'` - Date and time selection * - `'timeOnly'` - Time selection only * - `'monthOnly'` - Month and year selection only * * @default 'dayOnly' */ pickerAppearance?: DatePickerAppearance; /** * Display format for the date in the field cell (list views). * * Uses Unicode date format standards. * @see https://date-fns.org/docs/format * * @example 'MMM d, yyyy', 'yyyy-MM-dd', 'dd/MM/yyyy' */ displayFormat?: string; /** * Number of months to show in the date picker. * * Maximum of 2 months can be displayed simultaneously. * * @default 1 * @max 2 */ monthsToShow?: 1 | 2; /** * Minimum selectable date. * * Users cannot select dates before this date. * Can be a Date object or ISO date string. * * @example new Date('2024-01-01'), '2024-01-01' */ minDate?: Date | string; /** * Maximum selectable date. * * Users cannot select dates after this date. * Can be a Date object or ISO date string. * * @example new Date('2025-12-31'), '2025-12-31' */ maxDate?: Date | string; /** * Minimum selectable time. * * Only applies when `pickerAppearance` includes time selection. * Can be a Date object or time string (HH:mm format). * * @example new Date('2024-01-01T09:00:00'), '09:00' */ minTime?: Date | string; /** * Maximum selectable time. * * Only applies when `pickerAppearance` includes time selection. * Can be a Date object or time string (HH:mm format). * * @example new Date('2024-01-01T17:00:00'), '17:00' */ maxTime?: Date | string; /** * Time interval in minutes for time selection. * * Controls the granularity of time selection in the picker. * * @default 30 * @example 15, 30, 60 */ timeIntervals?: number; /** * Time display format. * * @default 'h:mm aa' * @example 'HH:mm', 'h:mm a', 'HH:mm:ss' */ timeFormat?: string; } /** * Admin panel options specific to date fields. * * Extends the base admin options with date picker configuration. */ interface DateFieldAdminOptions extends FieldAdminOptions { /** * Date picker configuration options. * * Controls the appearance and behavior of the date picker. */ date?: DatePickerOptions; } /** * Configuration interface for date fields. * * Date fields store date and/or time values. Dates are stored in UTC * format in the database. The field supports various picker appearances * and validation options. * * **Use Cases:** * - Publication dates * - Event start/end times * - Birth dates * - Appointment scheduling * - Expiration dates * * @example * ```typescript * // Basic date field * const publishDateField: DateFieldConfig = { * name: 'publishDate', * type: 'date', * label: 'Publish Date', * required: true, * }; * * // Date and time picker * const eventStartField: DateFieldConfig = { * name: 'eventStart', * type: 'date', * label: 'Event Start', * admin: { * date: { * pickerAppearance: 'dayAndTime', * timeIntervals: 15, * timeFormat: 'HH:mm', * }, * }, * }; * * // Date with min/max constraints * const appointmentField: DateFieldConfig = { * name: 'appointmentDate', * type: 'date', * label: 'Appointment Date', * admin: { * date: { * pickerAppearance: 'dayAndTime', * minDate: new Date(), // No past dates * minTime: '09:00', * maxTime: '17:00', * timeIntervals: 30, * }, * description: 'Select a date and time during business hours', * }, * }; * * // Month-only picker (e.g., for credit card expiration) * const expirationField: DateFieldConfig = { * name: 'expirationMonth', * type: 'date', * label: 'Expiration', * admin: { * date: { * pickerAppearance: 'monthOnly', * displayFormat: 'MM/yyyy', * }, * }, * }; * * // Time-only picker * const openingTimeField: DateFieldConfig = { * name: 'openingTime', * type: 'date', * label: 'Opening Time', * admin: { * date: { * pickerAppearance: 'timeOnly', * timeIntervals: 30, * timeFormat: 'h:mm aa', * }, * }, * }; * * // Date range validation * const birthDateField: DateFieldConfig = { * name: 'birthDate', * type: 'date', * label: 'Date of Birth', * required: true, * admin: { * date: { * displayFormat: 'MMMM d, yyyy', * maxDate: new Date(), // No future dates * }, * }, * validate: (value) => { * if (value) { * const date = new Date(value); * const age = Math.floor((Date.now() - date.getTime()) / (365.25 * 24 * 60 * 60 * 1000)); * if (age < 18) { * return 'You must be at least 18 years old'; * } * } * return true; * }, * }; * ``` * * @remarks * Timezone support is reserved for future implementation. * Currently, all dates are stored and displayed in UTC. */ interface DateFieldConfig extends Omit { /** * Field type identifier. Must be 'date'. */ type: "date"; /** * Default value for the field. * * Can be a static date or a function that returns a date. * * @example * ```typescript * // Static default (ISO string) * defaultValue: '2024-01-01' * * // Static default (Date object) * defaultValue: new Date('2024-01-01') * * // Dynamic default (current date) * defaultValue: () => new Date().toISOString() * ``` */ defaultValue?: string | Date | ((data: Record) => string | Date); /** * Admin UI configuration options. */ admin?: DateFieldAdminOptions; /** * Custom validation function. * * Receives the typed date value and returns `true` for valid * or an error message string for invalid. * * @param value - The date field value (string, Date, null, or undefined) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Ensure date is in the future * validate: (value) => { * if (value) { * const date = new Date(value); * if (date <= new Date()) { * return 'Date must be in the future'; * } * } * return true; * } * * // Ensure end date is after start date * validate: (value, { data }) => { * if (value && data.startDate) { * const endDate = new Date(value); * const startDate = new Date(data.startDate as string); * if (endDate <= startDate) { * return 'End date must be after start date'; * } * } * return true; * } * * // Validate business hours * validate: (value) => { * if (value) { * const date = new Date(value); * const hours = date.getUTCHours(); * if (hours < 9 || hours >= 17) { * return 'Please select a time during business hours (9 AM - 5 PM)'; * } * } * return true; * } * ``` */ validate?: (value: DateFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Email Field Type * * A specialized text field for email addresses. * Automatically validates email format and renders * with appropriate input type in the Admin UI. * * @module collections/fields/types/email * @since 1.0.0 */ /** * Possible value types for an email field. */ type EmailFieldValue = string | null | undefined; /** * Admin panel options specific to email fields. * * Extends the base admin options with email-specific settings. */ interface EmailFieldAdminOptions extends FieldAdminOptions { /** * HTML autocomplete attribute value. * * @default 'email' * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete */ autoComplete?: string; } /** * Configuration interface for email fields. * * Email fields are specialized text inputs that automatically * validate email format. They render with `type="email"` in the * Admin UI, providing browser-native email validation and * appropriate keyboard on mobile devices. * * Built-in validation ensures the value matches a standard email * pattern. Additional custom validation can be added via the * `validate` function. * * @example * ```typescript * // Basic email field * const emailField: EmailFieldConfig = { * name: 'email', * type: 'email', * label: 'Email Address', * required: true, * unique: true, * }; * * // Email field with custom validation * const workEmailField: EmailFieldConfig = { * name: 'workEmail', * type: 'email', * label: 'Work Email', * validate: (value) => { * if (value && !value.endsWith('@company.com')) { * return 'Must be a company email address'; * } * return true; * }, * }; * * // Contact email with description * const contactEmailField: EmailFieldConfig = { * name: 'contactEmail', * type: 'email', * label: 'Contact Email', * admin: { * description: 'This email will be used for notifications', * placeholder: 'you@nextly.local', * }, * }; * ``` */ interface EmailFieldConfig extends Omit { /** * Field type identifier. Must be 'email'. */ type: "email"; /** * Default value for the field. * * Can be a static string or a function that returns one. */ defaultValue?: string | ((data: Record) => string); /** * Admin UI configuration options. */ admin?: EmailFieldAdminOptions; /** * Nested validation knobs. Mirrors the Schema Builder shape so code-first * config and the Builder UI converge on one source of truth. */ validation?: FieldValidation; /** * Custom validation function. * * This runs in addition to the built-in email format validation. * Use this for custom rules like domain restrictions. * * @param value - The email field value (string, null, or undefined) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * validate: (value, { data }) => { * // Restrict to specific domains * const allowedDomains = ['company.com', 'partner.com']; * if (value) { * const domain = value.split('@')[1]; * if (!allowedDomains.includes(domain)) { * return 'Email must be from an allowed domain'; * } * } * return true; * } * ``` */ validate?: (value: EmailFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Group Field Type * * A field for nesting other fields under a common property. * Groups provide both data organization and visual grouping in the Admin UI. * * @module collections/fields/types/group * @since 1.0.0 */ /** * Permissive type alias for fields nested within a group. * * Uses a structural subset of BaseFieldConfig with `Record` * to accept any concrete field config (text, select, array, etc.) without * contravariance issues from narrowed validate/name properties. * * @internal */ type GroupFieldConfig_FieldConfig = { type: string; name?: string; label?: string; required?: boolean; [key: string]: any; }; /** * Value type for a group field. * * Group fields store an object containing nested field values, * keyed by field name. */ type GroupFieldValue = Record | null | undefined; /** * Admin panel options specific to group fields. * * Extends the base admin options with group-specific settings * for controlling visual presentation. */ interface GroupFieldAdminOptions extends FieldAdminOptions { /** * Hide the group's visual gutter (vertical line and padding). * * By default, groups display a vertical line on the left side * to visually indicate nesting. Set to `true` to remove this * visual indicator for a flatter appearance. * * @default false */ hideGutter?: boolean; } /** * Configuration interface for group fields. * * Group fields nest other fields under a common property, creating * both a data structure (nested object) and visual grouping in the * Admin UI. Groups can be "named" (with a `name` property) to create * nested data, or "presentational" (without `name`) for UI-only grouping. * * **Key Features:** * - Nest fields under a common property * - Visual grouping with optional gutter * - Support for deeply nested structures * - Can be presentational (no data nesting) or named (creates nested object) * * **Use Cases:** * - SEO metadata (title, description, keywords as a group) * - Address fields (street, city, state, zip grouped together) * - Social settings (links, sharing options grouped) * - Author information (name, bio, avatar grouped) * - Product dimensions (width, height, depth, weight) * * **Named vs Presentational Groups:** * * - **Named Group:** Has a `name` property. Data is stored under that property. * ```typescript * // Config * { name: 'seo', type: 'group', fields: [{ name: 'title' }] } * // Data: { seo: { title: 'My Title' } } * ``` * * - **Presentational Group:** No `name` property. Fields are stored at the parent level. * ```typescript * // Config * { type: 'group', label: 'SEO Settings', fields: [{ name: 'seoTitle' }] } * // Data: { seoTitle: 'My Title' } * ``` * * @example * ```typescript * // Named group - SEO metadata * const seo: GroupFieldConfig = { * name: 'seo', * type: 'group', * label: 'SEO Settings', * fields: [ * { * name: 'title', * type: 'text', * label: 'Meta Title', * maxLength: 60, * }, * { * name: 'description', * type: 'textarea', * label: 'Meta Description', * maxLength: 160, * }, * { * name: 'keywords', * type: 'text', * hasMany: true, * label: 'Keywords', * }, * ], * admin: { * description: 'Configure SEO settings for this page', * }, * }; * // Stored as: { seo: { title: '...', description: '...', keywords: [...] } } * * // Named group - Address * const address: GroupFieldConfig = { * name: 'address', * type: 'group', * label: 'Shipping Address', * fields: [ * { name: 'street', type: 'text', required: true }, * { name: 'city', type: 'text', required: true }, * { name: 'state', type: 'text', required: true }, * { name: 'zipCode', type: 'text', required: true }, * { name: 'country', type: 'select', options: ['US', 'CA', 'UK', 'AU'] }, * ], * }; * * // Named group - Social links with hidden gutter * const social: GroupFieldConfig = { * name: 'social', * type: 'group', * label: 'Social Media', * admin: { * hideGutter: true, * }, * fields: [ * { name: 'twitter', type: 'text', label: 'Twitter URL' }, * { name: 'facebook', type: 'text', label: 'Facebook URL' }, * { name: 'linkedin', type: 'text', label: 'LinkedIn URL' }, * { name: 'instagram', type: 'text', label: 'Instagram URL' }, * ], * }; * * // Nested groups - Author with contact details * const author: GroupFieldConfig = { * name: 'author', * type: 'group', * label: 'Author Information', * fields: [ * { name: 'name', type: 'text', required: true }, * { name: 'bio', type: 'textarea' }, * { name: 'avatar', type: 'upload', relationTo: 'media' }, * { * name: 'contact', * type: 'group', * label: 'Contact Details', * fields: [ * { name: 'email', type: 'email' }, * { name: 'phone', type: 'text' }, * { name: 'website', type: 'text' }, * ], * }, * ], * }; * * // Presentational group (no name) - just visual grouping * const settingsSection: GroupFieldConfig = { * type: 'group', * label: 'Display Settings', * admin: { * description: 'Configure how this content is displayed', * }, * fields: [ * { name: 'showTitle', type: 'checkbox', defaultValue: true }, * { name: 'showDate', type: 'checkbox', defaultValue: true }, * { name: 'showAuthor', type: 'checkbox', defaultValue: false }, * ], * }; * // Fields stored at parent level: { showTitle: true, showDate: true, ... } * * // Group with default values * const defaults: GroupFieldConfig = { * name: 'settings', * type: 'group', * label: 'Default Settings', * defaultValue: { * theme: 'light', * notifications: true, * language: 'en', * }, * fields: [ * { name: 'theme', type: 'select', options: ['light', 'dark', 'auto'] }, * { name: 'notifications', type: 'checkbox' }, * { name: 'language', type: 'select', options: ['en', 'es', 'fr', 'de'] }, * ], * }; * * // Group with conditional visibility * const advancedOptions: GroupFieldConfig = { * name: 'advanced', * type: 'group', * label: 'Advanced Options', * admin: { * condition: { * field: 'showAdvanced', * equals: true, * }, * }, * fields: [ * { name: 'cacheTimeout', type: 'number' }, * { name: 'customCSS', type: 'code', language: 'css' }, * { name: 'customJS', type: 'code', language: 'javascript' }, * ], * }; * ``` */ interface GroupFieldConfig extends Omit { /** * Field type identifier. Must be 'group'. */ type: "group"; /** * Unique field name (identifier). * * **Named groups:** When `name` is provided, fields are nested under * this property in the data structure. * * **Presentational groups:** When `name` is omitted, the group is * purely visual - fields are stored at the parent level. * * @example * ```typescript * // Named group - data stored under 'seo' property * { name: 'seo', type: 'group', fields: [...] } * * // Presentational group - fields stored at parent level * { type: 'group', label: 'SEO Settings', fields: [...] } * ``` */ name?: string; /** * Fields nested within this group. * * Supports any field type including nested groups and arrays * for complex data structures. */ fields: GroupFieldConfig_FieldConfig[]; /** * Default value for the group field. * * An object containing default values for nested fields. * Only applicable for named groups. * * @example * ```typescript * defaultValue: { * title: 'Default Title', * description: '', * keywords: [], * } * ``` */ defaultValue?: GroupFieldValue | ((data: Record) => GroupFieldValue); /** * Admin UI configuration options. */ admin?: GroupFieldAdminOptions; /** * Custom interface name for TypeScript generation. * * When specified, creates a reusable TypeScript interface with * this name that can be imported and used elsewhere. * * @example * ```typescript * interfaceName: 'SEOMetadata' * // Generates: export interface SEOMetadata { title: string; description?: string; } * ``` */ interfaceName?: string; /** * Custom database column/table name (SQL adapters only). * * By default, group data is stored using the field name. * Use this to specify a custom database identifier. */ dbName?: string; /** * Mark field as virtual (no database storage). * * When `true`, the field exists in the API but is not persisted * to the database. Useful for computed or derived fields. * * @default false */ virtual?: boolean; /** * Custom validation function. * * Receives the group value and returns `true` for valid * or an error message string for invalid. * * @param value - The group field value (object with nested values) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Validate that at least one social link is provided * validate: (value) => { * if (value) { * const hasLink = Object.values(value).some(v => v); * if (!hasLink) { * return 'Please provide at least one social link'; * } * } * return true; * } * * // Cross-field validation within group * validate: (value, { data }) => { * if (value?.endDate && value?.startDate) { * if (new Date(value.endDate) < new Date(value.startDate)) { * return 'End date must be after start date'; * } * } * return true; * } * ``` */ validate?: (value: GroupFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * JSON Field Type * * A flexible field that stores arbitrary JSON data. * Provides a code editor interface in the Admin UI with optional * JSON Schema validation for type safety and editor guidance. * * **Use Cases:** * - Storing configuration objects * - Custom metadata that varies per document * - API response caching * - Flexible data structures * - Settings that don't warrant dedicated fields * * @module collections/fields/types/json * @since 1.0.0 */ /** * Possible value types for a JSON field. * * Can store any valid JSON structure: objects, arrays, or primitives. * * @example * ```typescript * // Object value * const config: JSONFieldValue = { * theme: 'dark', * notifications: { email: true, push: false } * }; * * // Array value * const tags: JSONFieldValue = ['featured', 'new', 'sale']; * * // Null/undefined * const empty: JSONFieldValue = null; * ``` */ type JSONFieldValue = Record | unknown[] | string | number | boolean | null | undefined; /** * JSON Schema type keywords. * * Defines the allowed types for a JSON Schema property. */ type JSONSchemaType = "string" | "number" | "integer" | "boolean" | "object" | "array" | "null"; /** * JSON Schema property definition. * * A simplified subset of JSON Schema for inline schema definitions. * Supports the most common validation keywords. */ interface JSONSchemaProperty { /** * The type(s) allowed for this property. */ type?: JSONSchemaType | JSONSchemaType[]; /** * Human-readable description of the property. */ description?: string; /** * Default value for the property. */ default?: unknown; /** * Allowed values (enum). */ enum?: unknown[]; /** * Constant value. */ const?: unknown; /** * Minimum string length. */ minLength?: number; /** * Maximum string length. */ maxLength?: number; /** * Regex pattern the string must match. */ pattern?: string; /** * Format hint (e.g., 'email', 'uri', 'date-time'). */ format?: string; /** * Minimum value (inclusive). */ minimum?: number; /** * Maximum value (inclusive). */ maximum?: number; /** * Exclusive minimum value. */ exclusiveMinimum?: number; /** * Exclusive maximum value. */ exclusiveMaximum?: number; /** * Value must be a multiple of this number. */ multipleOf?: number; /** * Minimum number of items. */ minItems?: number; /** * Maximum number of items. */ maxItems?: number; /** * Whether items must be unique. */ uniqueItems?: boolean; /** * Schema for array items. */ items?: JSONSchemaProperty; /** * Property definitions for objects. */ properties?: Record; /** * Required property names. */ required?: string[]; /** * Whether additional properties are allowed. */ additionalProperties?: boolean | JSONSchemaProperty; /** * Minimum number of properties. */ minProperties?: number; /** * Maximum number of properties. */ maxProperties?: number; } /** * JSON Schema definition for field validation. * * Inline JSON Schema for validating and guiding JSON input. * Validation is performed at the application level for equal * support across all database adapters. * * @example * ```typescript * // Schema for a settings object * const settingsSchema: JSONSchemaDefinition = { * type: 'object', * properties: { * theme: { * type: 'string', * enum: ['light', 'dark', 'system'], * default: 'system', * }, * fontSize: { * type: 'integer', * minimum: 12, * maximum: 24, * default: 14, * }, * notifications: { * type: 'object', * properties: { * email: { type: 'boolean', default: true }, * push: { type: 'boolean', default: false }, * }, * }, * }, * required: ['theme'], * }; * ``` */ interface JSONSchemaDefinition extends JSONSchemaProperty { /** * JSON Schema version identifier. * * @example 'https://json-schema.org/draft/2020-12/schema' */ $schema?: string; /** * Schema title for documentation. */ title?: string; } /** * Editor configuration options for the JSON code editor. * * These options are passed to the code editor component * (e.g., Monaco, CodeMirror) in the Admin UI. */ interface JSONEditorOptions { /** * Height of the editor in pixels or CSS value. * * @default 300 * @example 400, '50vh', 'auto' */ height?: number | string; /** * Minimum height of the editor. * * @default 100 */ minHeight?: number; /** * Maximum height of the editor. * * When set, the editor becomes scrollable beyond this height. */ maxHeight?: number; /** * Whether to show line numbers. * * @default true */ lineNumbers?: boolean; /** * Whether to enable code folding. * * @default true */ folding?: boolean; /** * Whether to enable word wrap. * * @default false */ wordWrap?: boolean; /** * Whether to enable minimap (code overview). * * @default false */ minimap?: boolean; /** * Tab size for indentation. * * @default 2 */ tabSize?: number; /** * Whether to format JSON on blur. * * Automatically prettifies the JSON when the field loses focus. * * @default true */ formatOnBlur?: boolean; /** * Whether to validate JSON in real-time. * * Shows syntax errors as the user types. * * @default true */ validateOnChange?: boolean; } /** * Admin panel options specific to JSON fields. * * Extends the base admin options with JSON editor configuration. */ interface JSONFieldAdminOptions extends FieldAdminOptions { /** * Code editor configuration options. * * Customize the appearance and behavior of the JSON editor. */ editorOptions?: JSONEditorOptions; } /** * Configuration interface for JSON fields. * * JSON fields store arbitrary JSON data and provide a code editor * interface in the Admin UI. Optional JSON Schema validation ensures * data integrity and provides editor guidance (autocomplete, hints). * * **Database Storage:** * - PostgreSQL: `JSONB` (binary JSON with indexing support) * - MySQL: `JSON` (native JSON type) * - SQLite: `TEXT` (JSON string, parsed at application level) * * **Validation:** * JSON Schema validation is performed at the application level, * ensuring equal support across all database adapters. * * @example * ```typescript * // Basic JSON field (any valid JSON) * const metadataField: JSONFieldConfig = { * name: 'metadata', * type: 'json', * label: 'Metadata', * }; * * // JSON field with schema validation * const settingsField: JSONFieldConfig = { * name: 'settings', * type: 'json', * label: 'User Settings', * jsonSchema: { * type: 'object', * properties: { * theme: { * type: 'string', * enum: ['light', 'dark', 'system'], * description: 'UI theme preference', * }, * language: { * type: 'string', * pattern: '^[a-z]{2}(-[A-Z]{2})?$', * description: 'Locale code (e.g., en-US)', * }, * notifications: { * type: 'object', * properties: { * email: { type: 'boolean', default: true }, * push: { type: 'boolean', default: false }, * sms: { type: 'boolean', default: false }, * }, * }, * }, * required: ['theme'], * }, * }; * * // JSON field with custom editor options * const configField: JSONFieldConfig = { * name: 'config', * type: 'json', * label: 'Configuration', * admin: { * description: 'Advanced configuration in JSON format', * editorOptions: { * height: 400, * lineNumbers: true, * folding: true, * minimap: false, * tabSize: 2, * formatOnBlur: true, * }, * }, * }; * * // JSON field with default value * const preferencesField: JSONFieldConfig = { * name: 'preferences', * type: 'json', * label: 'Preferences', * defaultValue: { * displayMode: 'grid', * itemsPerPage: 20, * showThumbnails: true, * }, * }; * * // JSON array field * const tagsField: JSONFieldConfig = { * name: 'customTags', * type: 'json', * label: 'Custom Tags', * jsonSchema: { * type: 'array', * items: { * type: 'object', * properties: { * name: { type: 'string', minLength: 1 }, * color: { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' }, * }, * required: ['name'], * }, * minItems: 0, * maxItems: 10, * }, * }; * * // JSON field with custom validation * const apiConfigField: JSONFieldConfig = { * name: 'apiConfig', * type: 'json', * label: 'API Configuration', * validate: (value) => { * if (value && typeof value === 'object' && !Array.isArray(value)) { * const config = value as Record; * if (!config.endpoint || typeof config.endpoint !== 'string') { * return 'API endpoint is required'; * } * if (!config.endpoint.startsWith('https://')) { * return 'API endpoint must use HTTPS'; * } * } * return true; * }, * }; * ``` */ interface JSONFieldConfig extends Omit { /** * Field type identifier. Must be 'json'. */ type: "json"; /** * JSON Schema for validation and editor guidance. * * Inline schema definition that validates the JSON structure * and provides autocomplete hints in the editor. * * Validation is performed at the application level for * consistent behavior across all database adapters. */ jsonSchema?: JSONSchemaDefinition; /** * Default value for the field. * * Can be any valid JSON value or a function that returns one. * * @example * ```typescript * // Static default object * defaultValue: { enabled: true, count: 0 } * * // Static default array * defaultValue: [] * * // Dynamic default * defaultValue: () => ({ createdAt: new Date().toISOString() }) * ``` */ defaultValue?: Record | unknown[] | string | number | boolean | null | ((data: Record) => Record | unknown[] | string | number | boolean | null); /** * Admin UI configuration options. */ admin?: JSONFieldAdminOptions; /** * Custom validation function. * * Receives the JSON value and returns `true` for valid * or an error message string for invalid. * * This runs in addition to JSON Schema validation (if defined). * * @param value - The JSON field value * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Validate specific structure * validate: (value) => { * if (value && typeof value === 'object') { * const obj = value as Record; * if (!obj.version || typeof obj.version !== 'number') { * return 'Config must include a numeric version'; * } * } * return true; * } * * // Validate array length * validate: (value) => { * if (Array.isArray(value) && value.length > 100) { * return 'Maximum 100 items allowed'; * } * return true; * } * * // Cross-field validation * validate: (value, { data }) => { * if (value && data.type === 'advanced') { * const config = value as Record; * if (!config.advancedSettings) { * return 'Advanced settings required for advanced type'; * } * } * return true; * } * ``` */ validate?: (value: JSONFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Number Field Type * * A numeric input field. Stores whole numbers by default; opt into an exact * decimal column with `dbType: "decimal"` for money and other fractional * values. Supports single or multiple values (hasMany), min/max validation, * and customizable step increments. * * @module collections/fields/types/number * @since 1.0.0 */ /** * Possible value types for a number field. * * - `number` - Single numeric value (default) * - `number[]` - Multiple numeric values (when `hasMany: true`) * - `null` - Explicitly empty value * - `undefined` - Value not set */ type NumberFieldValue = number | number[] | null | undefined; /** * Admin panel options specific to number fields. * * Extends the base admin options with number-specific settings * like step increment and placeholder text. */ interface NumberFieldAdminOptions extends FieldAdminOptions { /** * Step increment for the number input. * * Controls the increment/decrement amount when using * spinner buttons or arrow keys. * * @example 1, 0.1, 0.01, 5, 10 * @default 1 */ step?: number; /** * Placeholder text displayed when the input is empty. * * @example 'Enter quantity', '0.00' */ placeholder?: string; } /** * Configuration interface for number fields. * * Number fields store numeric values (integers or decimals) and * support range validation, step increments, and multiple values. * * **Use Cases:** * - Quantities, counts, amounts * - Prices, percentages, ratings * - Coordinates, dimensions, measurements * - Any numeric data requiring validation * * @example * ```typescript * // Price field: exact decimal storage (integer is the default and truncates) * const priceField: NumberFieldConfig = { * name: 'price', * type: 'number', * label: 'Price', * required: true, * min: 0, * dbType: 'decimal', * scale: 2, * admin: { * step: 0.01, * placeholder: '0.00', * }, * }; * * // Number field with range validation * const ratingField: NumberFieldConfig = { * name: 'rating', * type: 'number', * label: 'Rating', * min: 1, * max: 5, * admin: { * step: 1, * description: 'Rate from 1 to 5 stars', * }, * }; * * // Number field with multiple values * const dimensionsField: NumberFieldConfig = { * name: 'dimensions', * type: 'number', * label: 'Dimensions (cm)', * hasMany: true, * minRows: 3, * maxRows: 3, * min: 0, * admin: { * description: 'Enter length, width, height', * }, * }; * * // Percentage field * const discountField: NumberFieldConfig = { * name: 'discount', * type: 'number', * label: 'Discount', * min: 0, * max: 100, * defaultValue: 0, * admin: { * step: 1, * description: 'Discount percentage (0-100%)', * }, * validate: (value) => { * if (value !== null && value !== undefined && !Number.isInteger(value)) { * return 'Discount must be a whole number'; * } * return true; * }, * }; * ``` */ interface NumberFieldConfig extends Omit { /** * Field type identifier. Must be 'number'. */ type: "number"; /** * Minimum allowed value. * * Validation will fail if the value is less than this number. * Use for range constraints like prices (min: 0) or ratings (min: 1). */ min?: number; /** * Maximum allowed value. * * Validation will fail if the value exceeds this number. * Use for range constraints like percentages (max: 100) or ratings (max: 5). */ max?: number; /** * Database storage type for the value. * * - `"integer"` (default) - whole numbers only; a fractional value is not * preserved (the database rounds or truncates it). Use for counts, * quantities, ratings, and IDs. * - `"decimal"` - a fixed-point `DECIMAL`/`NUMERIC` column for money and other * fractional values, sized with `precision` and `scale` (default * `DECIMAL(10, 2)`). Exact at rest on Postgres and MySQL; on SQLite it maps * to NUMERIC affinity (best-effort, since SQLite has no fixed-precision * decimal type). Values are read back as JavaScript numbers, so a precision * beyond what a double can represent (~15 significant digits) is not fully * round-tripped. * * @default "integer" * @example * ```typescript * // Store a price, e.g. 1234.56 * { name: 'price', type: 'number', dbType: 'decimal', precision: 10, scale: 2 } * ``` */ dbType?: "integer" | "decimal"; /** * Total number of significant digits for a `dbType: "decimal"` column. * Ignored for integer fields. * * @default 10 */ precision?: number; /** * Number of digits to the right of the decimal point for a * `dbType: "decimal"` column. Ignored for integer fields. * * @default 2 */ scale?: number; /** * Allow multiple numeric values. * * When `true`, the field accepts an array of numbers instead of a single number. * In the Admin UI, this renders as a multi-value input with add/remove buttons. * * @default false */ hasMany?: boolean; /** * Minimum number of items when `hasMany` is true. * * Validation will fail if fewer items are provided. */ minRows?: number; /** * Maximum number of items when `hasMany` is true. * * Validation will fail if more items are provided. * The Admin UI will disable the add button when this limit is reached. */ maxRows?: number; /** * Default value for the field. * * Can be a static number/array or a function that returns one. * * @example * ```typescript * // Static default * defaultValue: 0 * * // Dynamic default * defaultValue: () => Date.now() * * // Array default (when hasMany: true) * defaultValue: [0, 0, 0] * ``` */ defaultValue?: number | number[] | ((data: Record) => number | number[]); /** * Admin UI configuration options. */ admin?: NumberFieldAdminOptions; /** * Nested validation knobs. Mirrors the Schema Builder shape so code-first * config and the Builder UI converge on one source of truth. */ validation?: FieldValidation; /** * Custom validation function. * * Receives the typed number value and returns `true` for valid * or an error message string for invalid. Runs after built-in * min/max validation. * * @param value - The number field value (number, number[], null, or undefined) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Require integer values only * validate: (value) => { * if (value !== null && value !== undefined) { * if (Array.isArray(value)) { * if (!value.every(Number.isInteger)) { * return 'All values must be integers'; * } * } else if (!Number.isInteger(value)) { * return 'Value must be an integer'; * } * } * return true; * } * * // Require even numbers * validate: (value) => { * if (typeof value === 'number' && value % 2 !== 0) { * return 'Value must be an even number'; * } * return true; * } * * // Custom business logic * validate: (value, { data }) => { * if (typeof value === 'number' && data.type === 'premium' && value < 100) { * return 'Premium products must cost at least $100'; * } * return true; * } * ``` */ validate?: (value: NumberFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Password Field Type * * A secure text field for password input. Values are masked in the Admin * UI, bcrypt-hashed automatically before storage, and never returned by * any read or mutation response. * * @module collections/fields/types/password * @since 1.0.0 */ /** * Possible value types for a password field. */ type PasswordFieldValue = string | null | undefined; /** * Admin panel options specific to password fields. * * Extends the base admin options with password-specific settings. */ interface PasswordFieldAdminOptions extends FieldAdminOptions { /** * HTML autocomplete attribute value. * * Use 'new-password' for registration forms, * 'current-password' for login forms. * * @default 'new-password' * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete */ autoComplete?: "new-password" | "current-password" | "off"; /** * Show password strength indicator. * * When `true`, displays a visual indicator of password strength * based on length, character variety, and common patterns. * * @default false */ showStrengthIndicator?: boolean; } /** * Configuration interface for password fields. * * Password fields are specialized text inputs that mask user input. * They render with `type="password"` in the Admin UI. * * **Built-in security guarantees (no configuration needed):** * - Values are bcrypt-hashed on every write before storage; the plaintext * is never persisted. * - Stored hashes are write-only: reads, list responses, and mutation * responses never include the field's value, and `select` cannot opt it * back in. * - On update, an absent or empty value keeps the stored hash unchanged; * an explicit `null` clears it. * - Verify a submitted password against the stored hash with * `verifyPassword` from the auth utilities inside server code (a hook or * custom route) — the stored value is a hash, not the password. * * @example * ```typescript * // Password with strength requirements * const securePasswordField: PasswordFieldConfig = { * name: 'password', * type: 'password', * label: 'Password', * required: true, * minLength: 12, * maxLength: 128, * admin: { * showStrengthIndicator: true, * description: 'Must be at least 12 characters with mixed case and numbers', * }, * validate: (value) => { * if (typeof value !== 'string' || value === '') return true; * if (!/[A-Z]/.test(value)) return 'Must contain uppercase letter'; * if (!/[a-z]/.test(value)) return 'Must contain lowercase letter'; * if (!/[0-9]/.test(value)) return 'Must contain a number'; * return true; * }, * }; * ``` */ interface PasswordFieldConfig extends Omit { /** * Field type identifier. Must be 'password'. */ type: "password"; /** * Minimum length for the password. * * Recommended minimum is 8 characters for general use, * 12+ characters for high-security applications. * * @default 8 */ minLength?: number; /** * Maximum length for the password. * * Should be set high enough to allow passphrases. * Most hashing algorithms handle up to 72-128 bytes. */ maxLength?: number; /** * Default value for the field. * * **Warning:** Setting a default password is generally * not recommended for security reasons. */ defaultValue?: string | ((data: Record) => string); /** * Admin UI configuration options. */ admin?: PasswordFieldAdminOptions; /** * Nested validation knobs. Mirrors the Schema Builder shape so code-first * config and the Builder UI converge on one source of truth. Pattern * validation (regex) on password fields runs through the same Zod * pipeline as text and textarea. */ validation?: FieldValidation; /** * Custom validation function. * * Use this to enforce password complexity requirements * like mixed case, numbers, or special characters. * * @param value - The password field value (string, null, or undefined) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * validate: (value) => { * if (!value) return true; * * // Check for common weak passwords * const weakPasswords = ['password', '123456', 'qwerty']; * if (weakPasswords.includes(value.toLowerCase())) { * return 'Password is too common'; * } * * // Require special character * if (!/[!@#$%^&*(),.?":{}|<>]/.test(value)) { * return 'Password must contain a special character'; * } * * return true; * } * ``` */ validate?: (value: PasswordFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Select Field Type * * A dropdown selection field that allows choosing from predefined options. * Supports single or multiple selections, searchable dropdowns, and * dynamic option filtering. * * @module collections/fields/types/select * @since 1.0.0 */ /** * A single option in a select field. * * Options can be defined as objects with label and value, * where the label is displayed to users and the value is stored. * * @example * ```typescript * const option: SelectOption = { * label: 'Published', * value: 'published', * }; * ``` */ interface SelectOption { /** * Display text shown to users in the dropdown. */ label: string; /** * Value stored in the database when this option is selected. * * **Important:** Values should be strings without hyphens or special * characters due to GraphQL enumeration naming constraints. * Underscores are allowed. * * @example 'published', 'draft', 'pending_review' */ value: string; } /** * Possible value types for a select field. * * - `string` - Single selected value (default) * - `string[]` - Multiple selected values (when `hasMany: true`) * - `null` - Explicitly empty value * - `undefined` - Value not set */ type SelectFieldValue = string | string[] | null | undefined; /** * Arguments passed to the filterOptions function. */ interface FilterOptionsArgs { /** * The current document data being edited. */ data: Record; /** * Data from sibling fields (fields at the same level in arrays/groups). */ siblingData: Record; /** * The current user making the request. */ user: RequestContext$1["user"]; } /** * Function to dynamically filter available options. * * Allows options to be filtered based on document data, sibling data, * or user context. Useful for cascading dropdowns or role-based options. * * @param args - Filter arguments with data and user context * @returns Filtered array of options or a Promise resolving to options * * @example * ```typescript * // Filter categories based on selected parent * filterOptions: ({ data }) => { * const parentId = data.parentCategory; * return allCategories.filter(cat => cat.parentId === parentId); * } * * // Role-based option filtering * filterOptions: ({ user }) => { * if (user?.role === 'admin') { * return allOptions; * } * return allOptions.filter(opt => !opt.adminOnly); * } * ``` */ type FilterOptionsFunction = (args: FilterOptionsArgs) => SelectOption[] | Promise; /** * Admin panel options specific to select fields. * * Extends the base admin options with select-specific settings * like clearable and sortable options. */ interface SelectFieldAdminOptions extends FieldAdminOptions { /** * Allow users to clear the selection. * * When `true`, displays a clear button to remove the selected value. * * @default false */ isClearable?: boolean; /** * Allow drag-and-drop reordering of selected items. * * Only applies when `hasMany: true`. Enables users to reorder * their selections by dragging. * * @default false */ isSortable?: boolean; } /** * Configuration interface for select fields. * * Select fields provide a dropdown interface for choosing from * predefined options. They support single or multiple selections, * custom validation, and dynamic option filtering. * * **Use Cases:** * - Status fields (draft, published, archived) * - Category selection * - Priority levels * - Country/region selection * - Role assignment * * @example * ```typescript * // Basic select field * const statusField: SelectFieldConfig = { * name: 'status', * type: 'select', * label: 'Status', * required: true, * defaultValue: 'draft', * options: [ * { label: 'Draft', value: 'draft' }, * { label: 'Published', value: 'published' }, * { label: 'Archived', value: 'archived' }, * ], * }; * * // Multi-select field * const categoriesField: SelectFieldConfig = { * name: 'categories', * type: 'select', * label: 'Categories', * hasMany: true, * options: [ * { label: 'Technology', value: 'technology' }, * { label: 'Business', value: 'business' }, * { label: 'Design', value: 'design' }, * { label: 'Marketing', value: 'marketing' }, * ], * admin: { * isClearable: true, * isSortable: true, * description: 'Select one or more categories', * }, * }; * * // Select with dynamic filtering * const subcategoryField: SelectFieldConfig = { * name: 'subcategory', * type: 'select', * label: 'Subcategory', * options: allSubcategories, * filterOptions: ({ data }) => { * const parentCategory = data.category as string; * return allSubcategories.filter(sub => sub.parentId === parentCategory); * }, * admin: { * condition: { * field: 'category', * exists: true, * }, * }, * }; * * // Priority field with custom validation * const priorityField: SelectFieldConfig = { * name: 'priority', * type: 'select', * label: 'Priority', * options: [ * { label: 'Low', value: 'low' }, * { label: 'Medium', value: 'medium' }, * { label: 'High', value: 'high' }, * { label: 'Critical', value: 'critical' }, * ], * validate: (value, { data }) => { * if (data.type === 'bug' && value !== 'high' && value !== 'critical') { * return 'Bugs must have high or critical priority'; * } * return true; * }, * }; * * // Unique select (e.g., primary contact) * const primaryContactField: SelectFieldConfig = { * name: 'primaryContact', * type: 'select', * label: 'Primary Contact', * unique: true, * options: contactOptions, * admin: { * isClearable: true, * }, * }; * ``` */ interface SelectFieldConfig extends Omit { /** * Field type identifier. Must be 'select'. */ type: "select"; /** * Available options for selection. * * Array of options with label (displayed) and value (stored). * * **Important:** Option values should be strings without hyphens * or special characters due to GraphQL enumeration naming constraints. * Underscores are allowed. */ options: SelectOption[]; /** * Allow multiple selections. * * When `true`, the field accepts an array of values instead of * a single value. Renders as a multi-select dropdown. * * @default false */ hasMany?: boolean; /** * Custom enum name for SQL databases and TypeScript generation. * * If not provided, an enum name will be auto-generated from * the collection and field name. * * @example 'PostStatus', 'UserRole' */ enumName?: string; /** * Interface name for TypeScript and GraphQL type generation. * * Creates a reusable top-level type that can be referenced * elsewhere in your schema. * * @example 'Status', 'Priority' */ interfaceName?: string; /** * Function to dynamically filter available options. * * Allows options to be filtered based on document data, * sibling data, or user context. */ filterOptions?: FilterOptionsFunction; /** * Default value for the field. * * Can be a static value or a function that returns a value. * For `hasMany: true`, provide an array of values. * * @example * ```typescript * // Single default * defaultValue: 'draft' * * // Multiple defaults (when hasMany: true) * defaultValue: ['technology', 'design'] * * // Dynamic default * defaultValue: (data) => data.isUrgent ? 'high' : 'medium' * ``` */ defaultValue?: string | string[] | ((data: Record) => string | string[]); /** * Admin UI configuration options. */ admin?: SelectFieldAdminOptions; /** * Custom validation function. * * Receives the typed select value and returns `true` for valid * or an error message string for invalid. * * @param value - The select field value (string, string[], null, or undefined) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Ensure at least 2 categories selected * validate: (value) => { * if (Array.isArray(value) && value.length < 2) { * return 'Please select at least 2 categories'; * } * return true; * } * * // Validate against other field values * validate: (value, { data }) => { * if (value === 'published' && !data.title) { * return 'Cannot publish without a title'; * } * return true; * } * ``` */ validate?: (value: SelectFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Radio Field Type * * A radio button group field that allows selecting a single value * from predefined options. Unlike select fields, all options are * visible at once. * * @module collections/fields/types/radio * @since 1.0.0 */ /** * Possible value types for a radio field. * * Radio fields always store a single value (unlike select which can have hasMany). * * - `string` - Selected option value * - `null` - Explicitly empty value * - `undefined` - Value not set */ type RadioFieldValue = string | null | undefined; /** * Layout direction for radio button options. */ type RadioLayout = "horizontal" | "vertical"; /** * Admin panel options specific to radio fields. * * Extends the base admin options with radio-specific settings * like layout direction. */ interface RadioFieldAdminOptions extends FieldAdminOptions { /** * Layout direction for radio buttons. * * - `'horizontal'` - Options displayed in a row (default) * - `'vertical'` - Options displayed in a column * * @default 'horizontal' */ layout?: RadioLayout; } /** * Configuration interface for radio fields. * * Radio fields display all options as radio buttons, allowing * users to select exactly one value. Unlike select dropdowns, * all options are visible at once, making them ideal for small * sets of mutually exclusive choices. * * **Use Cases:** * - Yes/No/Maybe choices * - Size selection (S, M, L, XL) * - Rating scales with few options * - Priority levels * - Payment methods * * @example * ```typescript * // Basic radio field * const genderField: RadioFieldConfig = { * name: 'gender', * type: 'radio', * label: 'Gender', * options: [ * { label: 'Male', value: 'male' }, * { label: 'Female', value: 'female' }, * { label: 'Other', value: 'other' }, * { label: 'Prefer not to say', value: 'not_specified' }, * ], * admin: { * layout: 'vertical', * }, * }; * * // Size selection with horizontal layout * const sizeField: RadioFieldConfig = { * name: 'size', * type: 'radio', * label: 'Size', * required: true, * defaultValue: 'medium', * options: [ * { label: 'S', value: 'small' }, * { label: 'M', value: 'medium' }, * { label: 'L', value: 'large' }, * { label: 'XL', value: 'xlarge' }, * ], * admin: { * layout: 'horizontal', * }, * }; * * // Payment method selection * const paymentMethodField: RadioFieldConfig = { * name: 'paymentMethod', * type: 'radio', * label: 'Payment Method', * required: true, * options: [ * { label: 'Credit Card', value: 'credit_card' }, * { label: 'PayPal', value: 'paypal' }, * { label: 'Bank Transfer', value: 'bank_transfer' }, * ], * admin: { * layout: 'vertical', * description: 'Select your preferred payment method', * }, * }; * * // Rating with custom validation * const satisfactionField: RadioFieldConfig = { * name: 'satisfaction', * type: 'radio', * label: 'How satisfied are you?', * required: true, * options: [ * { label: 'Very Dissatisfied', value: '1' }, * { label: 'Dissatisfied', value: '2' }, * { label: 'Neutral', value: '3' }, * { label: 'Satisfied', value: '4' }, * { label: 'Very Satisfied', value: '5' }, * ], * admin: { * layout: 'horizontal', * }, * }; * * // Conditional radio field * const shippingSpeedField: RadioFieldConfig = { * name: 'shippingSpeed', * type: 'radio', * label: 'Shipping Speed', * options: [ * { label: 'Standard (5-7 days)', value: 'standard' }, * { label: 'Express (2-3 days)', value: 'express' }, * { label: 'Overnight', value: 'overnight' }, * ], * defaultValue: 'standard', * admin: { * layout: 'vertical', * condition: { * field: 'requiresShipping', * equals: true, * }, * }, * }; * * // With custom enum name for TypeScript generation * const statusField: RadioFieldConfig = { * name: 'approvalStatus', * type: 'radio', * label: 'Approval Status', * enumName: 'ApprovalStatus', * interfaceName: 'ApprovalStatusType', * options: [ * { label: 'Pending', value: 'pending' }, * { label: 'Approved', value: 'approved' }, * { label: 'Rejected', value: 'rejected' }, * ], * }; * ``` */ interface RadioFieldConfig extends Omit { /** * Field type identifier. Must be 'radio'. */ type: "radio"; /** * Available options for selection. * * Array of options with label (displayed) and value (stored). * All options are displayed as radio buttons. * * **Important:** Option values should be strings without hyphens * or special characters due to GraphQL enumeration naming constraints. * Underscores are allowed. */ options: SelectOption[]; /** * Custom enum name for SQL databases and TypeScript generation. * * If not provided, an enum name will be auto-generated from * the collection and field name. * * @example 'ShippingSpeed', 'PaymentMethod' */ enumName?: string; /** * Interface name for TypeScript and GraphQL type generation. * * Creates a reusable top-level type that can be referenced * elsewhere in your schema. * * @example 'ShippingSpeedType', 'PaymentMethodType' */ interfaceName?: string; /** * Default value for the field. * * Must be one of the values defined in the `options` array. * Can be a static value or a function that returns a value. * * @example * ```typescript * // Static default * defaultValue: 'medium' * * // Dynamic default * defaultValue: (data) => data.isPremium ? 'express' : 'standard' * ``` */ defaultValue?: string | ((data: Record) => string); /** * Admin UI configuration options. */ admin?: RadioFieldAdminOptions; /** * Custom validation function. * * Receives the typed radio value and returns `true` for valid * or an error message string for invalid. * * @param value - The radio field value (string, null, or undefined) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Validate based on other field values * validate: (value, { data }) => { * if (value === 'overnight' && data.weight > 50) { * return 'Overnight shipping not available for items over 50kg'; * } * return true; * } * * // Role-based validation * validate: (value, { req }) => { * if (value === 'approved' && req.user?.role !== 'admin') { * return 'Only admins can set status to approved'; * } * return true; * } * ``` */ validate?: (value: RadioFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Relationship Field Type * * A field for creating references between documents in different collections. * Supports single or multiple relationships, polymorphic relations to * multiple collections, and filtering available documents. * * @module collections/fields/types/relationship * @since 1.0.0 */ /** * Reference to a related document (single collection). * * When `relationTo` is a single string, the value is just the document ID. */ type RelationshipSingleValue = string | null | undefined; /** * Reference to a related document (polymorphic/multiple collections). * * When `relationTo` is an array of collection slugs, the value includes * both the collection slug and document ID to identify which collection * the related document belongs to. * * @example * ```typescript * // Single polymorphic reference * const value: RelationshipPolymorphicValue = { * relationTo: 'users', * value: 'abc123', * }; * ``` */ interface RelationshipPolymorphicValue { /** * The collection slug this relationship points to. */ relationTo: string; /** * The document ID of the related document. */ value: string; } /** * Possible value types for a relationship field. * * The value type depends on the `relationTo` and `hasMany` configuration: * * - `relationTo: string` + `hasMany: false` → `string | null | undefined` * - `relationTo: string` + `hasMany: true` → `string[] | null | undefined` * - `relationTo: string[]` + `hasMany: false` → `RelationshipPolymorphicValue | null | undefined` * - `relationTo: string[]` + `hasMany: true` → `RelationshipPolymorphicValue[] | null | undefined` */ type RelationshipFieldValue = string | string[] | RelationshipPolymorphicValue | RelationshipPolymorphicValue[] | null | undefined; /** * Arguments passed to the filterOptions function. */ interface RelationshipFilterOptionsArgs { /** * The collection slug being filtered. * When `relationTo` is an array, this indicates which collection * the filter is being applied to. */ relationTo: string; /** * The current document data being edited. */ data: Record; /** * Data from sibling fields (fields at the same level in arrays/groups). */ siblingData: Record; /** * The ID of the current document being edited. * Undefined during create operations. */ id?: string; /** * The current user making the request. */ user?: RequestContext$1["user"]; /** * The full request context. */ req: RequestContext$1; /** * Parent block data when this field is within a blocks field. * Undefined if not inside a block. */ blockData?: Record; } /** * Where query for filtering available related documents. * * Since relationships can filter on any document field, this uses * a flexible record type that matches the Nextly Where query syntax. * * @example * ```typescript * // Filter by status field * const publishedOnly: RelationshipFilterQuery = { * status: { equals: 'published' }, * }; * * // Filter by category * const categoryFilter: RelationshipFilterQuery = { * category: { in: ['news', 'blog'] }, * }; * * // Complex filter with AND/OR * const complexFilter: RelationshipFilterQuery = { * and: [ * { status: { equals: 'published' } }, * { or: [ * { category: { equals: 'featured' } }, * { priority: { greater_than: 5 } }, * ]}, * ], * }; * ``` */ type RelationshipFilterQuery = Record; /** * Function to dynamically filter available related documents. * * Allows documents to be filtered based on the current document data, * user context, or other dynamic conditions. Return values: * - `true` - No filtering, show all documents * - `false` - Prevent all documents from being shown * - `RelationshipFilterQuery` - Apply the specified Where query filter * * @param args - Filter arguments with context * @returns Filter result or Promise resolving to filter result * * @example * ```typescript * // Role-based filtering * filterOptions: ({ user }) => { * if (user?.role === 'admin') { * return true; // Admins see all documents * } * return { status: { equals: 'published' } }; // Others only see published * } * * // Context-aware filtering - only show users from same organization * filterOptions: ({ data }) => { * if (data.organizationId) { * return { organizationId: { equals: data.organizationId } }; * } * return true; * } * * // Polymorphic filtering - different filters per collection * filterOptions: ({ relationTo, user }) => { * if (relationTo === 'users') { * return { role: { not_equals: 'admin' } }; * } * if (relationTo === 'posts') { * return { status: { equals: 'published' } }; * } * return true; * } * ``` */ type RelationshipFilterOptionsFunction = (args: RelationshipFilterOptionsArgs) => boolean | RelationshipFilterQuery | Promise; /** * Filter options for relationship fields. * * Can be either a static Where query or a dynamic function. */ type RelationshipFilterOptions = RelationshipFilterQuery | RelationshipFilterOptionsFunction; /** * Sort options for the relationship field dropdown. * * Controls how related documents are sorted when displayed in the * Admin UI dropdown. Can be: * - A field name string (ascending order) * - An object mapping collection slugs to field names (for polymorphic) * * @example * ```typescript * // Simple sort by title * sortOptions: 'title' * * // Sort by title descending * sortOptions: '-title' * * // Per-collection sorting (polymorphic) * sortOptions: { * users: 'lastName', * posts: '-createdAt', * categories: 'name', * } * ``` */ type RelationshipSortOptions = string | Record; /** * UI appearance options for the relationship field. * * - `select` - Standard dropdown selector (default) * - `drawer` - Opens a drawer/modal for document selection */ type RelationshipAppearance = "select" | "drawer"; /** * Admin panel options specific to relationship fields. * * Extends the base admin options with relationship-specific settings * for controlling the document picker behavior. */ interface RelationshipFieldAdminOptions extends FieldAdminOptions { /** * Allow creating new related documents directly from the field. * * When `true`, displays a "Create New" button that allows users to * create new documents in the related collection without leaving * the current form. * * @default true */ allowCreate?: boolean; /** * Allow editing related documents from within the field. * * When `true`, displays an edit button that opens the related * document for editing (in a drawer or new tab). * * @default true */ allowEdit?: boolean; /** * Allow drag-and-drop reordering of selected relationships. * * Only applies when `hasMany: true`. Enables users to reorder * their selections by dragging. * * @default true */ isSortable?: boolean; /** * Default sort order for documents in the dropdown. * * Can be a field name (prefix with `-` for descending) or an object * mapping collection slugs to field names for polymorphic relationships. * * @example * ```typescript * // Sort by title ascending * sortOptions: 'title' * * // Sort by createdAt descending * sortOptions: '-createdAt' * * // Per-collection sorting * sortOptions: { * users: 'email', * posts: '-publishedAt', * } * ``` */ sortOptions?: RelationshipSortOptions; /** * UI appearance style for the relationship picker. * * - `select` - Standard dropdown (default, good for small lists) * - `drawer` - Opens a drawer/modal (better for large lists with search) * * @default 'select' */ appearance?: RelationshipAppearance; } /** * Configuration interface for relationship fields. * * Relationship fields create references between documents in different * collections. They're one of the most powerful field types, enabling * complex data relationships and content structures. * * **Key Features:** * - Reference documents from one or multiple collections * - Filter available documents by any field * - Support for single or multiple selections * - Create/edit related documents directly from the field * - Polymorphic relationships (multiple target collections) * * **Use Cases:** * - Author/user relationships on posts * - Category/tag assignments * - Parent-child hierarchies * - Cross-referencing content * - Many-to-many relationships * * @example * ```typescript * // Basic single relationship - Post author * const author: RelationshipFieldConfig = { * name: 'author', * type: 'relationship', * label: 'Author', * relationTo: 'users', * required: true, * filterOptions: { * role: { in: ['author', 'editor', 'admin'] }, * }, * }; * * // Has many relationship - Post categories * const categories: RelationshipFieldConfig = { * name: 'categories', * type: 'relationship', * label: 'Categories', * relationTo: 'categories', * hasMany: true, * minRows: 1, * maxRows: 5, * admin: { * description: 'Select 1-5 categories', * isSortable: true, * }, * }; * * // Polymorphic relationship - Media can reference multiple collections * const relatedContent: RelationshipFieldConfig = { * name: 'relatedContent', * type: 'relationship', * label: 'Related Content', * relationTo: ['posts', 'pages', 'products'], * hasMany: true, * admin: { * description: 'Link to related posts, pages, or products', * sortOptions: { * posts: '-publishedAt', * pages: 'title', * products: 'name', * }, * }, * }; * * // Self-referencing relationship - Parent page * const parentPage: RelationshipFieldConfig = { * name: 'parent', * type: 'relationship', * label: 'Parent Page', * relationTo: 'pages', * filterOptions: ({ id }) => { * // Exclude self from options to prevent circular reference * if (id) { * return { id: { not_equals: id } }; * } * return true; * }, * }; * * // Dynamic filtering based on document data * const teamMembers: RelationshipFieldConfig = { * name: 'teamMembers', * type: 'relationship', * label: 'Team Members', * relationTo: 'users', * hasMany: true, * filterOptions: ({ data }) => { * // Only show users from the same organization * if (data.organizationId) { * return { organizationId: { equals: data.organizationId } }; * } * return true; * }, * }; * * // Relationship with drawer appearance for large lists * const products: RelationshipFieldConfig = { * name: 'featuredProducts', * type: 'relationship', * label: 'Featured Products', * relationTo: 'products', * hasMany: true, * maxRows: 10, * admin: { * appearance: 'drawer', * allowCreate: false, * sortOptions: '-sales', * }, * }; * ``` */ interface RelationshipFieldConfig extends Omit { /** * Field type identifier. Must be 'relationship'. */ type: "relationship"; /** * Collection(s) that this field can reference. * * Must be a collection slug or array of slugs. When using an array, * the relationship becomes "polymorphic" and can reference documents * from any of the specified collections. * * **Single collection:** * ```typescript * relationTo: 'users' * ``` * * **Multiple collections (polymorphic):** * ```typescript * relationTo: ['users', 'organizations', 'teams'] * ``` * * When using multiple collections, the field value includes * a `relationTo` property to identify which collection the * related document belongs to. */ relationTo: string | string[]; /** * Allow multiple document references. * * When `true`, the field accepts an array of document references * instead of a single reference. * * @default false */ hasMany?: boolean; /** * Minimum number of relationships when `hasMany` is true. * * Validation will fail if fewer relationships are selected. */ minRows?: number; /** * Maximum number of relationships when `hasMany` is true. * * Validation will fail if more relationships are selected. * The Admin UI will disable the add button when this limit is reached. */ maxRows?: number; /** * Maximum depth for populating related documents. * * Limits how deeply related documents are populated when querying. * Useful for controlling response size and preventing circular refs. * * @default 1 */ maxDepth?: number; /** * Filter options for available related documents. * * Can be a static Where query or a dynamic function that returns * a filter based on context (document data, user, etc.). * * **Note:** When using both `filterOptions` and a custom `validate` * function, the API will not automatically validate against filterOptions. * Include filter validation in your custom validate function if needed. * * @example * ```typescript * // Static filter - only published documents * filterOptions: { * status: { equals: 'published' }, * } * * // Dynamic filter - exclude self-reference * filterOptions: ({ id }) => { * if (id) { * return { id: { not_equals: id } }; * } * return true; * } * * // Role-based filtering * filterOptions: ({ user }) => { * if (user?.role !== 'admin') { * return { public: { equals: true } }; * } * return true; * } * ``` */ filterOptions?: RelationshipFilterOptions; /** * Default value for the field. * * Can be a static value or a function that returns a value. * * @example * ```typescript * // Single relationship default * defaultValue: 'default-category-id' * * // Multiple relationships default * defaultValue: ['category1-id', 'category2-id'] * * // Polymorphic default * defaultValue: { relationTo: 'users', value: 'default-user-id' } * * // Dynamic default based on current user * defaultValue: ({ user }) => user?.id * ``` */ defaultValue?: string | string[] | RelationshipPolymorphicValue | RelationshipPolymorphicValue[] | ((data: Record) => string | string[] | RelationshipPolymorphicValue | RelationshipPolymorphicValue[]); /** * Admin UI configuration options. */ admin?: RelationshipFieldAdminOptions; /** * Custom validation function. * * Receives the relationship field value and returns `true` for valid * or an error message string for invalid. * * **Note:** When using `filterOptions` with a custom `validate` * function, the filter constraints are not automatically validated. * You should include filter validation in your custom function if needed. * * @param value - The relationship field value * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Require at least 2 categories * validate: (value) => { * if (Array.isArray(value) && value.length < 2) { * return 'Please select at least 2 categories'; * } * return true; * } * * // Conditional requirement * validate: (value, { data }) => { * if (data.featured && !value) { * return 'Featured items must have an author'; * } * return true; * } * * // Prevent self-reference * validate: (value, { data }) => { * if (value === data.id) { * return 'Cannot reference self'; * } * return true; * } * ``` */ validate?: (value: RelationshipFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Repeater Field Type * * A field for storing repeating sets of fields. Each row in the repeater * contains the same field structure, allowing for lists of complex data. * * @module collections/fields/types/repeater * @since 1.0.0 */ /** * Permissive type alias for fields nested within a repeater. * * Uses a structural subset of BaseFieldConfig with an index signature * to accept any concrete field config (text, select, group, etc.) without * contravariance issues from narrowed validate/name properties. * * @internal */ type FieldConfig$1 = { type: string; name?: string; label?: string; required?: boolean; [key: string]: any; }; /** * Value type for a single repeater row. * * Each row is an object containing field values keyed by field name. */ type RepeaterRowValue = Record; /** * Value type for a repeater field. * * Repeater fields store an array of row objects, or null/undefined if empty. */ type RepeaterFieldValue = RepeaterRowValue[] | null | undefined; /** * Props passed to custom RowLabel components. * * RowLabel components render the label for each row in the repeater, * allowing dynamic labels based on row data. * * @example * ```typescript * const CustomRowLabel: React.FC = ({ data, index }) => { * return {data.title || `Item ${index + 1}`}; * }; * ``` */ interface RepeaterRowLabelProps { /** * The data for this specific repeater row. */ data: RepeaterRowValue; /** * The zero-based index of this row in the repeater. */ index: number; /** * The full path to this row in the document structure. */ path: string; } /** * Custom labels for repeater field UI. * * Allows customization of how repeater rows are labeled in the Admin UI. */ interface RepeaterFieldLabels { /** * Singular label for a single row (e.g., "Item", "Entry", "Slide"). * * Used in buttons like "Add {singular}" and row headers. */ singular?: string; /** * Plural label for multiple rows (e.g., "Items", "Entries", "Slides"). * * Used in section headers and descriptions. */ plural?: string; } /** * Admin panel options specific to repeater fields. * * Extends the base admin options with repeater-specific settings * for controlling row display and interaction. */ interface RepeaterFieldAdminOptions extends FieldAdminOptions { /** * Whether repeater rows should be initially collapsed. * * When `true`, rows are rendered in a collapsed state and must * be expanded to view/edit their contents. * * @default false */ initCollapsed?: boolean; /** * Whether rows can be reordered via drag-and-drop. * * When `true`, users can drag rows to reorder them. * Set to `false` to disable reordering. * * @default true */ isSortable?: boolean; /** * Custom components for repeater field rendering. */ components?: FieldAdminOptions["components"] & { /** * Custom component for rendering row labels. * * Allows dynamic labels based on row content instead of * the default "Item X" format. * * @example * ```typescript * components: { * RowLabel: ({ data, index }) => ( * {data.title || `Slide ${index + 1}`} * ), * } * ``` */ RowLabel?: React.ComponentType; }; } /** * Configuration interface for repeater fields. * * Repeater fields store repeating sets of fields, allowing users to add, * remove, and reorder rows of structured data. Each row contains the * same field structure defined in the `fields` property. * * **Key Features:** * - Repeating field groups with add/remove controls * - Drag-and-drop reordering * - Min/max row validation * - Collapsible rows for complex structures * - Custom row labels based on content * * **Use Cases:** * - Image galleries with captions * - FAQ sections (question/answer pairs) * - Team member lists * - Product features or specifications * - Timeline entries * - Social media links * * @example * ```typescript * // Basic repeater - social links * const socialLinks: RepeaterFieldConfig = { * name: 'socialLinks', * type: 'repeater', * label: 'Social Links', * labels: { * singular: 'Link', * plural: 'Links', * }, * fields: [ * { * name: 'platform', * type: 'select', * options: ['twitter', 'facebook', 'linkedin', 'instagram'], * required: true, * }, * { * name: 'url', * type: 'text', * required: true, * }, * ], * maxRows: 10, * }; * * // FAQ section with custom row labels * const faq: RepeaterFieldConfig = { * name: 'faq', * type: 'repeater', * label: 'Frequently Asked Questions', * labels: { * singular: 'Question', * plural: 'Questions', * }, * fields: [ * { * name: 'question', * type: 'text', * required: true, * }, * { * name: 'answer', * type: 'richText', * required: true, * }, * ], * admin: { * initCollapsed: true, * components: { * RowLabel: ({ data, index }) => ( * {data.question || `Question ${index + 1}`} * ), * }, * }, * }; * * // Image gallery with validation * const gallery: RepeaterFieldConfig = { * name: 'gallery', * type: 'repeater', * label: 'Image Gallery', * labels: { * singular: 'Image', * plural: 'Images', * }, * minRows: 1, * maxRows: 20, * fields: [ * { * name: 'image', * type: 'upload', * relationTo: 'media', * required: true, * }, * { * name: 'caption', * type: 'text', * }, * { * name: 'alt', * type: 'text', * required: true, * }, * ], * validate: (value) => { * if (!value || value.length === 0) { * return 'Please add at least one image'; * } * return true; * }, * }; * * // Nested repeaters - product variants with options * const variants: RepeaterFieldConfig = { * name: 'variants', * type: 'repeater', * label: 'Product Variants', * fields: [ * { * name: 'name', * type: 'text', * required: true, * }, * { * name: 'sku', * type: 'text', * required: true, * }, * { * name: 'price', * type: 'number', * required: true, * }, * { * name: 'options', * type: 'repeater', * fields: [ * { name: 'name', type: 'text' }, * { name: 'value', type: 'text' }, * ], * }, * ], * }; * ``` */ interface RepeaterFieldConfig extends Omit { /** * Field type identifier. Must be 'repeater'. */ type: "repeater"; /** * Fields that make up each row of the repeater. * * Each row will contain all these fields. Supports any field type * including nested repeaters and groups for complex data structures. */ fields: FieldConfig$1[]; /** * Minimum number of rows required. * * Validation will fail if fewer rows are present. */ minRows?: number; /** * Maximum number of rows allowed. * * Validation will fail if more rows are present. * The Admin UI will disable the add button when this limit is reached. */ maxRows?: number; /** * Custom labels for repeater rows. * * Used in the Admin UI for buttons like "Add {singular}" and * section headers showing "{plural}". */ labels?: RepeaterFieldLabels; /** * Default value for the repeater field. * * An array of row data objects to use as initial values. * * @example * ```typescript * defaultValue: [ * { platform: 'twitter', url: 'https://twitter.com/example' }, * { platform: 'linkedin', url: 'https://linkedin.com/in/example' }, * ] * ``` */ defaultValue?: RepeaterRowValue[] | ((data: Record) => RepeaterRowValue[]); /** * Admin UI configuration options. */ admin?: RepeaterFieldAdminOptions; /** * Custom interface name for TypeScript generation. * * When specified, creates a reusable TypeScript interface with * this name that can be imported and used elsewhere. * * @example * ```typescript * interfaceName: 'SocialLink' * // Generates: export interface SocialLink { platform: string; url: string; } * ``` */ interfaceName?: string; /** * Custom database table name (SQL adapters only). * * By default, repeater data is stored in a separate table with an * auto-generated name. Use this to specify a custom table name. */ dbName?: string; /** * Mark field as virtual (no database storage). * * When `true`, the field exists in the API but is not persisted * to the database. Useful for computed or derived fields. * * @default false */ virtual?: boolean; /** * Custom validation function. * * Receives the repeater value and returns `true` for valid * or an error message string for invalid. * * @param value - The repeater field value * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Require at least 3 items * validate: (value) => { * if (!value || value.length < 3) { * return 'Please add at least 3 items'; * } * return true; * } * * // Validate unique values within repeater * validate: (value) => { * if (value) { * const names = value.map(row => row.name); * const unique = new Set(names); * if (names.length !== unique.size) { * return 'All item names must be unique'; * } * } * return true; * } * ``` */ validate?: (value: RepeaterFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Text Field Type * * A basic text input field that stores a string value. * Supports single or multiple values (hasMany), length validation, * and custom validation functions. * * @module collections/fields/types/text * @since 1.0.0 */ /** * Possible value types for a text field. * * - `string` - Single text value (default) * - `string[]` - Multiple text values (when `hasMany: true`) * - `null` - Explicitly empty value * - `undefined` - Value not set */ type TextFieldValue = string | string[] | null | undefined; /** * Admin panel options specific to text fields. * * Extends the base admin options with text-specific settings * like autoComplete and input type. */ interface TextFieldAdminOptions extends FieldAdminOptions { /** * HTML autocomplete attribute value. * * Helps browsers provide relevant auto-fill suggestions. * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete * * @example 'name', 'email', 'tel', 'off' */ autoComplete?: string; } /** * Configuration interface for text fields. * * Text fields are the most basic input type, storing a single string * or an array of strings. They support length validation and can be * configured for various text input scenarios. * * @example * ```typescript * // Basic text field * const titleField: TextFieldConfig = { * name: 'title', * type: 'text', * label: 'Title', * required: true, * maxLength: 200, * }; * * // Text field with multiple values * const tagsField: TextFieldConfig = { * name: 'tags', * type: 'text', * label: 'Tags', * hasMany: true, * admin: { * description: 'Enter tags separated by Enter', * }, * }; * * // Text field with custom validation * const slugField: TextFieldConfig = { * name: 'slug', * type: 'text', * label: 'URL Slug', * unique: true, * validate: (value) => { * if (value && !/^[a-z0-9-]+$/.test(value)) { * return 'Slug can only contain lowercase letters, numbers, and hyphens'; * } * return true; * }, * }; * ``` */ interface TextFieldConfig extends Omit { /** * Field type identifier. Must be 'text'. */ type: "text"; /** * Minimum length for the text value. * * Validation will fail if the string length is less than this value. * Only applies to non-empty values (empty/null values are handled by `required`). */ minLength?: number; /** * Maximum length for the text value. * * Validation will fail if the string length exceeds this value. * Also used to set the database column size. */ maxLength?: number; /** * Allow multiple text values. * * When `true`, the field accepts an array of strings instead of a single string. * In the Admin UI, this renders as a tag-style input. * * @default false */ hasMany?: boolean; /** * Minimum number of items when `hasMany` is true. */ minRows?: number; /** * Maximum number of items when `hasMany` is true. */ maxRows?: number; /** * Default value for the field. * * Can be a static string/array or a function that returns one. */ defaultValue?: string | string[] | ((data: Record) => string | string[]); /** * Admin UI configuration options. */ admin?: TextFieldAdminOptions; /** * Nested validation knobs. Mirrors the shape the Schema Builder writes, * so code-first config and the Visual Schema Builder converge on one * source of truth. The renderer reads either the flat fields above * (`minLength`, `maxLength`) or this nested object — newly written code * should prefer this shape. * * @example * ```typescript * text({ * name: "slug", * required: true, * validation: { * pattern: "^[a-z-]+$", * message: "Slug must be lowercase with hyphens only", * }, * }) * ``` */ validation?: FieldValidation; /** * Custom validation function. * * Receives the typed text value and returns `true` for valid * or an error message string for invalid. * * @param value - The text field value (string, string[], null, or undefined) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * validate: (value, { data }) => { * if (value && value.includes('forbidden')) { * return 'Value cannot contain "forbidden"'; * } * return true; * } * ``` */ validate?: (value: TextFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Textarea Field Type * * A multi-line text input field for longer text content. * Similar to text fields but renders as a textarea element * with configurable rows. * * @module collections/fields/types/textarea * @since 1.0.0 */ /** * Possible value types for a textarea field. */ type TextareaFieldValue = string | null | undefined; /** * Admin panel options specific to textarea fields. * * Extends the base admin options with textarea-specific settings * like rows and resizing behavior. */ interface TextareaFieldAdminOptions extends FieldAdminOptions { /** * Number of visible text rows. * * Sets the initial height of the textarea. * @default 3 */ rows?: number; /** * Resize behavior for the textarea. * * - `'vertical'` - Allow vertical resizing only (default) * - `'horizontal'` - Allow horizontal resizing only * - `'both'` - Allow resizing in both directions * - `'none'` - Disable resizing */ resize?: "vertical" | "horizontal" | "both" | "none"; } /** * Configuration interface for textarea fields. * * Textarea fields are used for multi-line text input, such as * descriptions, summaries, or any content that may span multiple lines. * They differ from text fields in that they render as a resizable * textarea element. * * @example * ```typescript * // Basic textarea field * const descriptionField: TextareaFieldConfig = { * name: 'description', * type: 'textarea', * label: 'Description', * maxLength: 1000, * }; * * // Textarea with custom rows * const contentField: TextareaFieldConfig = { * name: 'content', * type: 'textarea', * label: 'Content', * required: true, * admin: { * rows: 10, * resize: 'vertical', * placeholder: 'Enter your content here...', * }, * }; * * // Textarea with length validation * const summaryField: TextareaFieldConfig = { * name: 'summary', * type: 'textarea', * label: 'Summary', * minLength: 50, * maxLength: 500, * admin: { * description: 'Write a brief summary (50-500 characters)', * }, * }; * ``` */ interface TextareaFieldConfig extends Omit { /** * Field type identifier. Must be 'textarea'. */ type: "textarea"; /** * Minimum length for the text value. * * Validation will fail if the string length is less than this value. * Only applies to non-empty values. */ minLength?: number; /** * Maximum length for the text value. * * Validation will fail if the string length exceeds this value. */ maxLength?: number; /** * Default value for the field. * * Can be a static string or a function that returns one. */ defaultValue?: string | ((data: Record) => string); /** * Admin UI configuration options. */ admin?: TextareaFieldAdminOptions; /** * Nested validation knobs. Mirrors the Schema Builder shape so code-first * config and the Builder UI converge. The renderer reads either the flat * `minLength` / `maxLength` above or this object — newly written code * should prefer this nested form. */ validation?: FieldValidation; /** * Custom validation function. * * Receives the typed textarea value and returns `true` for valid * or an error message string for invalid. * * @param value - The textarea field value (string, null, or undefined) * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * validate: (value, { data }) => { * if (value && value.split('\n').length > 10) { * return 'Content cannot exceed 10 lines'; * } * return true; * } * ``` */ validate?: (value: TextareaFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Upload Field Type * * A field for selecting files from upload-enabled collections. * Supports single or multiple uploads, polymorphic relations to * multiple collections, and filtering by file properties. * * @module collections/fields/types/upload * @since 1.0.0 */ /** * Reference to an uploaded file (single collection). * * When `relationTo` is a single string, the value is just the document ID. */ type UploadSingleValue = string | null | undefined; /** * Reference to an uploaded file (polymorphic/multiple collections). * * When `relationTo` is an array of collection slugs, the value includes * both the collection slug and document ID to identify which collection * the upload belongs to. * * @example * ```typescript * // Single polymorphic reference * const value: UploadPolymorphicValue = { * relationTo: 'images', * value: 'abc123', * }; * ``` */ interface UploadPolymorphicValue { /** * The collection slug this upload belongs to. */ relationTo: string; /** * The document ID of the uploaded file. */ value: string; } /** * Possible value types for an upload field. * * The value type depends on the `relationTo` and `hasMany` configuration: * * - `relationTo: string` + `hasMany: false` → `string | null | undefined` * - `relationTo: string` + `hasMany: true` → `string[] | null | undefined` * - `relationTo: string[]` + `hasMany: false` → `UploadPolymorphicValue | null | undefined` * - `relationTo: string[]` + `hasMany: true` → `UploadPolymorphicValue[] | null | undefined` */ type UploadFieldValue = string | string[] | UploadPolymorphicValue | UploadPolymorphicValue[] | null | undefined; /** * String filter operators for upload filtering. */ interface StringFilterOperator { /** * Match values that equal the specified string. */ equals?: string; /** * Match values that do not equal the specified string. */ not_equals?: string; /** * Match values that contain the specified substring. */ contains?: string; /** * Match values that are in the specified array. */ in?: string[]; /** * Match values that are not in the specified array. */ not_in?: string[]; /** * Match values that exist (not null/undefined). */ exists?: boolean; } /** * Number filter operators for upload filtering. */ interface NumberFilterOperator { /** * Match values that equal the specified number. */ equals?: number; /** * Match values that do not equal the specified number. */ not_equals?: number; /** * Match values greater than the specified number. */ greater_than?: number; /** * Match values greater than or equal to the specified number. */ greater_than_equal?: number; /** * Match values less than the specified number. */ less_than?: number; /** * Match values less than or equal to the specified number. */ less_than_equal?: number; /** * Match values that exist (not null/undefined). */ exists?: boolean; } /** * Where query for filtering available uploads. * * Allows filtering uploads by various file properties like * mimeType, filesize, filename, dimensions, etc. * * @example * ```typescript * // Filter to only show images * const imageFilter: UploadFilterQuery = { * mimeType: { contains: 'image' }, * }; * * // Filter to show images under 5MB * const smallImageFilter: UploadFilterQuery = { * mimeType: { contains: 'image' }, * filesize: { less_than: 5000000 }, * }; * * // Filter by specific mime types * const documentFilter: UploadFilterQuery = { * mimeType: { in: ['application/pdf', 'application/msword'] }, * }; * ``` */ interface UploadFilterQuery { /** * Filter by MIME type (e.g., 'image/png', 'application/pdf'). * * @example * ```typescript * mimeType: { contains: 'image' } // All images * mimeType: { equals: 'image/png' } // Only PNG * mimeType: { in: ['image/jpeg', 'image/png'] } // JPEG or PNG * ``` */ mimeType?: StringFilterOperator; /** * Filter by file size in bytes. * * @example * ```typescript * filesize: { less_than: 5000000 } // Under 5MB * filesize: { greater_than: 1000 } // Over 1KB * ``` */ filesize?: NumberFilterOperator; /** * Filter by filename. * * @example * ```typescript * filename: { contains: 'thumbnail' } * filename: { not_equals: 'default.png' } * ``` */ filename?: StringFilterOperator; /** * Filter by image width in pixels. * Only applicable to image uploads. * * @example * ```typescript * width: { greater_than_equal: 1920 } // HD or larger * ``` */ width?: NumberFilterOperator; /** * Filter by image height in pixels. * Only applicable to image uploads. * * @example * ```typescript * height: { greater_than_equal: 1080 } // HD or larger * ``` */ height?: NumberFilterOperator; /** * Filter by alt text content. * * @example * ```typescript * alt: { exists: true } // Only uploads with alt text * ``` */ alt?: StringFilterOperator; /** * Additional custom filters. * Allows filtering by custom fields added to the upload collection. */ [key: string]: StringFilterOperator | NumberFilterOperator | undefined; } /** * Arguments passed to the filterOptions function. */ interface UploadFilterOptionsArgs { /** * The collection slug being filtered. * When `relationTo` is an array, this indicates which collection * the filter is being applied to. */ relationTo: string; /** * The current document data being edited. */ data: Record; /** * Data from sibling fields (fields at the same level in arrays/groups). */ siblingData: Record; /** * The ID of the current document being edited. * Undefined during create operations. */ id?: string; /** * The current user making the request. */ user?: RequestContext$1["user"]; /** * The full request context. */ req: RequestContext$1; } /** * Function to dynamically filter available uploads. * * Allows uploads to be filtered based on document data, user context, * or other dynamic conditions. Return values: * - `true` - No filtering, show all uploads * - `false` - Prevent all uploads from being shown * - `UploadFilterQuery` - Apply the specified filter * * @param args - Filter arguments with context * @returns Filter result or Promise resolving to filter result * * @example * ```typescript * // Role-based filtering * filterOptions: ({ user }) => { * if (user?.role === 'admin') { * return true; // Admins see all uploads * } * return { mimeType: { contains: 'image' } }; // Others only see images * } * * // Context-aware filtering * filterOptions: ({ data }) => { * if (data.type === 'hero') { * return { width: { greater_than_equal: 1920 } }; // Hero needs HD images * } * return true; * } * ``` */ type UploadFilterOptionsFunction = (args: UploadFilterOptionsArgs) => boolean | UploadFilterQuery | Promise; /** * Filter options for upload fields. * * Can be either a static Where query or a dynamic function. */ type UploadFilterOptions = UploadFilterQuery | UploadFilterOptionsFunction; /** * Admin panel options specific to upload fields. * * Extends the base admin options with upload-specific settings * for controlling the file picker behavior. */ interface UploadFieldAdminOptions extends FieldAdminOptions { /** * Allow creating new uploads directly from the field. * * When `true`, displays an upload button that allows users to * upload new files without leaving the current form. * * @default true */ allowCreate?: boolean; /** * Allow editing upload metadata from within the field. * * When `true`, displays an edit button that opens the upload * document for editing (e.g., alt text, title). * * @default true */ allowEdit?: boolean; /** * Allow drag-and-drop reordering of selected uploads. * * Only applies when `hasMany: true`. Enables users to reorder * their uploads by dragging. * * @default true */ isSortable?: boolean; /** * Display a preview thumbnail of the uploaded file. * * Overrides the related collection's `admin.displayPreview` setting. * Useful for showing image thumbnails in the form. * * @default true (inherited from collection) */ displayPreview?: boolean; } /** * Configuration interface for upload fields. * * Upload fields enable selection of files from collections that have * uploads enabled. They display thumbnails in the Admin Panel and * support single or multiple file selection. * * **Key Features:** * - Reference files from one or multiple upload collections * - Filter available uploads by mimeType, filesize, dimensions * - Support for single or multiple file selection * - Thumbnail previews in the Admin UI * - Create/edit uploads directly from the field * * **Use Cases:** * - Featured images for posts/pages * - Document attachments * - Media galleries * - Avatar/profile pictures * - Downloadable file links * * @example * ```typescript * // Basic single image upload * const featuredImage: UploadFieldConfig = { * name: 'featuredImage', * type: 'upload', * label: 'Featured Image', * relationTo: 'media', * required: true, * filterOptions: { * mimeType: { contains: 'image' }, * }, * }; * * // Multiple document uploads * const attachments: UploadFieldConfig = { * name: 'attachments', * type: 'upload', * label: 'Attachments', * relationTo: 'documents', * hasMany: true, * maxRows: 10, * filterOptions: { * mimeType: { in: ['application/pdf', 'application/msword'] }, * filesize: { less_than: 10000000 }, // 10MB limit * }, * admin: { * description: 'Upload up to 10 PDF or Word documents', * isSortable: true, * }, * }; * * // Polymorphic upload (multiple collections) * const media: UploadFieldConfig = { * name: 'media', * type: 'upload', * label: 'Media', * relationTo: ['images', 'videos', 'documents'], * hasMany: true, * admin: { * description: 'Attach images, videos, or documents', * }, * }; * * // Upload with dynamic filtering * const heroImage: UploadFieldConfig = { * name: 'heroImage', * type: 'upload', * label: 'Hero Image', * relationTo: 'media', * filterOptions: ({ data }) => { * // Require HD images for hero sections * return { * mimeType: { contains: 'image' }, * width: { greater_than_equal: 1920 }, * height: { greater_than_equal: 1080 }, * }; * }, * }; * * // Avatar with size limit * const avatar: UploadFieldConfig = { * name: 'avatar', * type: 'upload', * label: 'Profile Picture', * relationTo: 'media', * filterOptions: { * mimeType: { in: ['image/jpeg', 'image/png', 'image/webp'] }, * filesize: { less_than: 2000000 }, // 2MB limit * }, * admin: { * displayPreview: true, * allowCreate: true, * allowEdit: false, * }, * }; * ``` */ interface UploadFieldConfig extends Omit { /** * Field type identifier. Must be 'upload'. */ type: "upload"; /** * Collection(s) that this field can reference. * * Must be a collection slug or array of slugs for collections * that have uploads enabled (`upload: true` in collection config). * * **Single collection:** * ```typescript * relationTo: 'media' * ``` * * **Multiple collections (polymorphic):** * ```typescript * relationTo: ['images', 'documents', 'videos'] * ``` * * When using multiple collections, the field value includes * a `relationTo` property to identify which collection the * upload belongs to. */ relationTo: string | string[]; /** * Allow multiple file uploads. * * When `true`, the field accepts an array of upload references * instead of a single reference. * * @default false */ hasMany?: boolean; /** * Minimum number of uploads when `hasMany` is true. * * Validation will fail if fewer uploads are selected. */ minRows?: number; /** * Maximum number of uploads when `hasMany` is true. * * Validation will fail if more uploads are selected. * The Admin UI will disable the add button when this limit is reached. */ maxRows?: number; /** * Maximum depth for populating related documents. * * Limits how deeply related documents are populated when querying. * Useful for controlling response size and preventing circular refs. * * @default 1 */ maxDepth?: number; /** * Maximum file size in bytes for uploads. * * Files exceeding this size will be rejected before upload. * * @example * ```typescript * // 5MB limit * maxFileSize: 5 * 1024 * 1024 * ``` */ maxFileSize?: number; /** * MIME type filter pattern for allowed uploads. * * @example * ```typescript * // Only images * mimeTypes: 'image/*' * * // Specific types * mimeTypes: 'image/png,image/jpeg,application/pdf' * ``` */ mimeTypes?: string; /** * Filter options for available uploads. * * Can be a static Where query or a dynamic function that returns * a filter based on context (document data, user, etc.). * * @example * ```typescript * // Static filter - only images * filterOptions: { * mimeType: { contains: 'image' }, * } * * // Dynamic filter - based on document type * filterOptions: ({ data }) => { * if (data.type === 'document') { * return { mimeType: { contains: 'application/pdf' } }; * } * return { mimeType: { contains: 'image' } }; * } * ``` */ filterOptions?: UploadFilterOptions; /** * Default value for the field. * * Can be a static value or a function that returns a value. * * @example * ```typescript * // Single upload default * defaultValue: 'default-image-id' * * // Multiple uploads default * defaultValue: ['image1-id', 'image2-id'] * * // Polymorphic default * defaultValue: { relationTo: 'images', value: 'default-id' } * ``` */ defaultValue?: string | string[] | UploadPolymorphicValue | UploadPolymorphicValue[] | ((data: Record) => string | string[] | UploadPolymorphicValue | UploadPolymorphicValue[]); /** * Admin UI configuration options. */ admin?: UploadFieldAdminOptions; /** * Custom validation function. * * Receives the upload field value and returns `true` for valid * or an error message string for invalid. * * **Note:** When using `filterOptions` with a custom `validate` * function, the filter constraints are not automatically validated. * You should include filter validation in your custom function if needed. * * @param value - The upload field value * @param args - Object containing document data and request context * @returns `true` if valid, or an error message string * * @example * ```typescript * // Require at least 3 images for gallery * validate: (value) => { * if (Array.isArray(value) && value.length < 3) { * return 'Gallery requires at least 3 images'; * } * return true; * } * * // Conditional requirement * validate: (value, { data }) => { * if (data.showFeaturedImage && !value) { * return 'Featured image is required when enabled'; * } * return true; * } * ``` */ validate?: (value: UploadFieldValue, args: { data: Record; req: RequestContext$1; }) => string | true | Promise; } /** * Authoring a field whose type a plugin contributed. * * `DataFieldConfig` is a closed union of the built-in shapes, which is what * makes a malformed built-in a compile error: `{ type: "select" }` without its * `options` matches no arm. A contributed type has no arm — its id belongs to * whichever plugin is installed — so a code-first declaration of one did not * type-check at all, and could only be written with a cast. * * Widening the union with an open arm would lose the property that closes it: * `string & {}` accepts every literal, so a malformed `select` would satisfy * the open arm instead of failing against its own. A symbol nothing else can * name is what keeps the two apart, and `pluginField()` is the only thing that * sets it — so reaching the open arm is a decision the author made rather than * a shape falling through to it. * * The same arrangement `pluginUserField()` uses for the users surface, for the * same reason. Kept symmetrical deliberately: one mechanism to learn, and a * plugin author moving between surfaces meets no second convention. * * @module collections/fields/types/plugin-field */ /** * The marker that admits a declaration to the open arm. * * `Symbol.for` rather than a fresh symbol: two copies of this module — a * pnpm-duplicated install, a bundler that did not dedupe — must agree on the * brand, or a field marked by one would not be recognised by the other. */ declare const pluginFieldBrand: unique symbol; /** A field declaration whose type a plugin contributed. */ interface PluginFieldInput { name: string; /** * The contributed type's id. Open, because it belongs to a plugin rather * than to this union. */ type: string & {}; label?: string; required?: boolean; /** * Options belonging to the field's own plugin type. * * Optional: a type may take none, and one whose option names collide with * nothing the built-in shapes declare may write them directly on the field. * Requiring an empty container would make this narrower than the runtime. */ pluginOptions?: Record; /** Anything else the declared type reads for itself. */ [option: string]: unknown; } /** The same declaration once `pluginField()` has marked it. */ interface PluginDataFieldConfig extends PluginFieldInput { readonly [pluginFieldBrand]: true; } /** * Declare a field whose type a plugin contributed. * * A built-in token is refused: marking one would put it on the open arm, where * its own shape is never checked — `{ type: "select" }` would satisfy the * union without the `options` a select requires, which is exactly what the * marker exists to prevent. * * @example * ```ts * defineCollection({ * slug: "pages", * fields: [text({ name: "title" }), pluginField({ name: "score", type: "star-rating" })], * }) * ``` */ declare function pluginField(field: T & (T["type"] extends FieldType ? { type: "this is a built-in field type; declare it with its own factory so its shape is checked"; } : unknown)): T & PluginDataFieldConfig; /** * What a config may declare a field as: a built-in shape, or a contributed one * that went through `pluginField()`. * * Only the authoring surfaces use this. `FieldConfig` itself stays a closed * union of the built-in shapes, because a member carrying an index signature * widens property access across the whole union — `field.minLength` would * become `{} | null` for every consumer that reads it. The openness a * contributed type needs belongs at the boundary where a schema is written, * not in the type every internal reader shares. */ type AuthorableFieldConfig = FieldConfig | PluginDataFieldConfig; /** * Field Types - Public Exports * * Re-exports all field type definitions for external consumption. * Provides unified FieldConfig type and field type categorization. * * @module collections/fields/types * @since 1.0.0 */ /** * Union of all field configurations. * * This is the primary type used when working with fields in Nextly. * It covers all data-storing field types. * * @example * ```typescript * const fields: FieldConfig[] = [ * { type: 'text', name: 'title', required: true }, * { type: 'relationship', name: 'author', relationTo: 'users' }, * ]; * ``` */ type DataFieldConfig = TextFieldConfig | TextareaFieldConfig | RichTextFieldConfig | EmailFieldConfig | PasswordFieldConfig | CodeFieldConfig | NumberFieldConfig | CheckboxFieldConfig | DateFieldConfig | SelectFieldConfig | RadioFieldConfig | UploadFieldConfig | RelationshipFieldConfig | RepeaterFieldConfig | GroupFieldConfig | JSONFieldConfig | FieldGroupFieldConfig | ChipsFieldConfig; /** * Alias for FieldConfig — all fields store data in the database. * * @deprecated Use `FieldConfig` directly. `DataFieldConfig` is kept * for backwards compatibility. */ type FieldConfig = DataFieldConfig; /** * Field type string union — all supported field types. * Extracted from FieldType for type-safe constant arrays. */ type DataFieldType = "text" | "textarea" | "richText" | "email" | "password" | "code" | "number" | "checkbox" | "date" | "select" | "radio" | "upload" | "relationship" | "repeater" | "group" | "json" | "component" | "chips"; /** * Array of all supported field types. * * Use this constant for runtime type checking and filtering. * * @example * ```typescript * if (DATA_FIELD_TYPES.includes(field.type)) { * // This field stores data * generateDatabaseColumn(field); * } * ``` */ declare const DATA_FIELD_TYPES: readonly DataFieldType[]; /** * Array of all supported field types. * * @example * ```typescript * if (!ALL_FIELD_TYPES.includes(field.type)) { * throw new Error(`Unknown field type: ${field.type}`); * } * ``` */ declare const ALL_FIELD_TYPES: readonly FieldType[]; /** * Component Configuration Types * * Type definitions for Components (Reusable Field Groups). * Components are shared, reusable field structures that can be created * independently and then selected from within Collections and Singles. * * Key characteristics: * - Components are templates (schemas), not documents * - Each instance is unique to its parent entry * - Support all field types available in Collections * - Separate database table per component type (comp_{slug}) * - Dual creation: Code-First (defineFieldGroup) and Schema Builder * * @module field-groups/config/types * @since 1.0.0 */ /** * Display label for a Component. * * Components only need a singular label since the label is used * in the component selector, sidebar navigation, and builder UI. * * @example * ```typescript * const label: FieldGroupLabel = { * singular: 'SEO Metadata', * }; * ``` */ interface FieldGroupLabel { /** * Singular display name for the Component. * Used in the Admin UI sidebar, component selector, breadcrumbs, * and page titles. * * @example 'SEO Metadata', 'Hero Section', 'Call To Action' */ singular: string; } /** * Admin panel configuration options for a Component. * * Controls how the Component appears and behaves in the Admin UI, * including sidebar navigation, the component selector modal, * and the component builder page. * * @example * ```typescript * const admin: FieldGroupAdminOptions = { * category: 'Shared', * icon: 'Search', * description: 'Search engine optimization metadata', * }; * ``` */ interface FieldGroupAdminOptions { /** * Category for organizing Components in the sidebar and selection UI. * * Components with the same category appear together under a common * heading in the sidebar navigation and the component selector modal. * * @example 'Shared', 'Blocks', 'Elements', 'Layout' */ category?: string; /** * Icon identifier for the Component. * Should be a valid icon name from the icon library (e.g., Lucide). * Displayed in the sidebar, component selector, and builder header. * * @example 'Search', 'Image', 'Link', 'Type', 'Layout' */ icon?: string; /** * Hide the Component from Admin UI navigation. * The Component is still accessible via direct URL and API, * and can still be used in Collections and Singles. * * @default false */ hidden?: boolean; /** * Description text displayed below the Component title. * Shown in the component selector modal and builder page * to provide helpful context for editors. * * @example 'Search engine optimization metadata for pages' */ description?: string; /** * Preview image URL shown in the component selector. * Provides a visual preview of the Component's intended layout * or appearance to help editors choose the right component. * * @example '/images/components/hero-preview.png' */ imageURL?: string; } /** * Complete Component configuration interface. * * This is the main interface for defining a Component in code. * Only `slug` and `fields` are required; all other properties have defaults. * * Components are reusable field group templates: * - Define a set of fields once as a Component * - Use the Component in multiple Collections and Singles * - Each usage creates a separate data instance in the Component's table * - Table naming: `comp_` prefix (e.g., `comp_seo`, `comp_hero`) * * @example * ```typescript * import { defineFieldGroup, text, upload } from 'nextly'; * * export default defineFieldGroup({ * slug: 'seo', * label: { singular: 'SEO Metadata' }, * admin: { * category: 'Shared', * icon: 'Search', * description: 'Search engine optimization metadata', * }, * fields: [ * text({ name: 'metaTitle', required: true, label: 'Meta Title' }), * text({ name: 'metaDescription', label: 'Meta Description' }), * upload({ name: 'metaImage', relationTo: 'media', label: 'OG Image' }), * text({ name: 'canonicalUrl', label: 'Canonical URL' }), * ], * }); * ``` * * @example Hero Section Component * ```typescript * import { defineFieldGroup, text, upload, select } from 'nextly'; * * export default defineFieldGroup({ * slug: 'hero', * label: { singular: 'Hero Section' }, * admin: { * category: 'Blocks', * icon: 'Image', * description: 'Full-width hero banner with heading and CTA', * }, * fields: [ * text({ name: 'heading', required: true, label: 'Heading' }), * text({ name: 'subheading', label: 'Subheading' }), * upload({ name: 'backgroundImage', relationTo: 'media', label: 'Background Image' }), * text({ name: 'ctaText', label: 'CTA Button Text' }), * text({ name: 'ctaLink', label: 'CTA Button Link' }), * select({ * name: 'alignment', * label: 'Content Alignment', * options: [ * { label: 'Left', value: 'left' }, * { label: 'Center', value: 'center' }, * { label: 'Right', value: 'right' }, * ], * defaultValue: 'center', * }), * ], * }); * ``` */ interface FieldGroupConfig { /** * Unique identifier for the Component. * * Used as the database table name prefix and reference key. * Must be: * - Unique across all Components, Collections, AND Singles * - URL-friendly (lowercase, no spaces) * - Not a reserved name * * @example 'seo', 'hero', 'cta', 'social-link' */ slug: string; /** * Field definitions for the Component. * * An array of field configurations that define the Component's structure. * Supports all field types available in Collections (text, number, * select, relationship, upload, array, group, json, etc.). * * @example * ```typescript * fields: [ * text({ name: 'heading', required: true }), * text({ name: 'subheading' }), * upload({ name: 'image', relationTo: 'media' }), * ] * ``` */ fields: FieldConfig[]; /** * Display label for the Admin UI. * If not provided, the label is auto-generated from the slug * (e.g., 'social-link' becomes 'Social Link'). * * @example * ```typescript * label: { singular: 'SEO Metadata' } * ``` */ label?: FieldGroupLabel; /** * Admin panel configuration options. * Controls how the Component appears in the Admin UI sidebar, * component selector, and builder page. */ admin?: FieldGroupAdminOptions; /** * Description of the Component. * * Displayed in the Admin UI and used for documentation. * If not provided, falls back to `admin.description`. */ description?: string; /** * Enable multilingual content for this Component. When `true`, translatable * fields inside it store a value per configured locale (text-like fields * localize by default; override per field with the field's `localized` flag). * Requires a `localization` block in the app config. * * @default false */ localized?: boolean; /** * Custom metadata for plugins and extensions. * * Store arbitrary data that can be accessed by hooks, plugins, * or custom code. Not persisted to the database. * * @example * ```typescript * custom: { * previewTemplate: 'hero-preview', * allowedCollections: ['pages', 'posts'], * } * ``` */ custom?: Record; } /** * Dialect-Agnostic Type Definitions for Dynamic Components * * These types define the structure for the `dynamic_components` metadata table * and are used by all dialect-specific schemas (PostgreSQL, MySQL, SQLite). * * Components are shared, reusable field group templates that can be created * independently and then selected from within Collections and Singles via * the `component` field type. * * Key differences from Dynamic Collections and Singles: * - `label` is singular only (like Singles, no plural form needed) * - No `accessRules` (Components are templates/schemas, not documents) * - No `hooks` (Components don't have lifecycle hooks) * - No `timestamps` configuration * - Table name convention: `comp_` prefix (e.g., 'comp_seo') * - `admin.category` for sidebar grouping (instead of `admin.group`) * * @module schemas/dynamic-field-groups/types * @since 1.0.0 */ /** * Source of the Component definition. * * - `code`: Defined in code via `defineFieldGroup()` in a config file * - `ui`: Created through the Visual Component Builder in Admin UI * * @example * ```typescript * const source: FieldGroupSource = 'code'; * ``` */ type FieldGroupSource = "code" | "ui"; /** * Migration status for a Component's schema. * * - `synced`: Schema is in sync with the database (no pending changes) * - `pending`: Schema has changed but migration not yet created * - `generated`: Migration file has been created but not applied * - `applied`: Migration has been applied to the database (table verified to exist) * - `failed`: Migration was attempted but table creation failed. RETRIABLE: the table is not * there, so making it again is the repair. * - `diverged`: The tables were changed and the row recording it was NOT written. NOT RETRIABLE, * and that is the whole reason it is its own state rather than a `failed`. The stored definition * describes the PREVIOUS shape while the tables hold the new one, so repeating the edit derives * its starting point from a row that is already wrong — a localization enable would seed the * companion a second time from main-table columns the first attempt already dropped. Reconcile * the definition against the tables before editing the field group again. * * @example * ```typescript * if (component.migrationStatus === 'pending') { * console.log('Run `nextly migrate:create` to generate migration'); * } * if (component.migrationStatus === 'failed') { * console.log('Table creation failed - check logs and retry'); * } * if (component.migrationStatus === 'diverged') { * console.log('Tables moved but the record did not - reconcile, do NOT retry'); * } * ``` */ /** * 🔴 DERIVED from the runtime list below, which is the single declaration of this set. * * The two used to be written out separately, and that is not a stylistic point: the list was * annotated `readonly FieldGroupMigrationStatus[]`, and an array missing an element still satisfies * that annotation — so a status added to the type and forgotten in the list compiled cleanly and * silently stopped being accepted anywhere the list is used to validate. * * Deriving the type from the value makes the value the source. Add a status in one place and every * union, every validator and every exhaustive switch sees it at once; there is no second place that * can be forgotten, because there is no second place. */ type FieldGroupMigrationStatus$1 = (typeof FIELD_GROUP_MIGRATION_STATUSES)[number]; /** * Insert type for creating a new dynamic Component. * * Contains all required and optional fields for inserting a Component * into the `dynamic_components` table. Fields with defaults (like * `schemaVersion`, `migrationStatus`) are optional on insert. * * @example * ```typescript * const newComponent: DynamicFieldGroupInsert = { * slug: 'seo', * label: 'SEO Metadata', * tableName: 'comp_seo', * fields: [ * { type: 'text', name: 'metaTitle', required: true }, * { type: 'text', name: 'metaDescription' }, * ], * source: 'code', * schemaHash: 'abc123...', * }; * ``` */ interface DynamicFieldGroupInsert { /** * Unique slug identifier for the Component. * Used in API references and component field selections. * Must be unique across all Components, Collections, AND Singles. */ slug: string; /** * Display label for the Admin UI. * Components only need a singular label (used in sidebar, * component selector, and builder). * * @example 'SEO Metadata', 'Hero Section', 'Call To Action' */ label: string; /** * Database table name for this Component's data. * Must be unique across all tables. * Convention: prefix with `comp_` (e.g., 'comp_seo', 'comp_hero'). */ tableName: string; /** * Optional description of the Component's purpose. * Displayed in the Admin UI component selector and builder. */ description?: string; /** * Field configurations defining the Component's structure. * Supports all field types including nested component fields. */ fields: FieldConfig[]; /** * Admin UI configuration options. * Controls category grouping, icon, visibility, etc. */ admin?: FieldGroupAdminOptions; /** * Where the Component was defined. * - 'code': defineFieldGroup() in a config file * - 'ui': Visual Component Builder */ source: FieldGroupSource; /** * If true, the Component cannot be modified via the Admin UI. * Code-first Components are locked by default. */ locked?: boolean; /** * i18n: whether the component is localized. When true, translatable fields live in * the companion `comp__locales` table and embedded instances resolve/write them * per language. */ localized?: boolean; /** * Path to the config file (code-first Components only). * Used for syncing and displaying source location. * * @example "src/components/seo.ts" */ configPath?: string; /** * SHA-256 hash of the fields definition. * Used for change detection during sync operations. */ schemaHash: string; /** * Schema version number, incremented on each change. * Defaults to 1 for new Components. */ schemaVersion?: number; /** * Current migration status. * Defaults to 'pending' for new Components. */ migrationStatus?: FieldGroupMigrationStatus$1; /** * Reference to the last applied migration ID. * Null for Components that haven't been migrated yet. */ lastMigrationId?: string; /** * User ID who created the Component (optional). * Only set for UI-created Components. */ createdBy?: string; } /** * Full record type for a dynamic Component. * * Extends `DynamicFieldGroupInsert` with all required fields that are * set by the database (id, timestamps) or have default values. * * @example * ```typescript * const component: DynamicFieldGroupRecord = { * id: 'uuid-123', * slug: 'seo', * label: 'SEO Metadata', * tableName: 'comp_seo', * fields: [...], * source: 'code', * locked: true, * schemaHash: 'abc123...', * schemaVersion: 1, * migrationStatus: 'applied', * createdAt: new Date(), * updatedAt: new Date(), * }; * ``` */ interface DynamicFieldGroupRecord extends DynamicFieldGroupInsert { /** * Unique identifier (UUID or CUID). * Auto-generated by the database. */ id: string; /** * Schema version number (required, starts at 1). */ schemaVersion: number; /** * Current migration status (required). */ migrationStatus: FieldGroupMigrationStatus$1; /** * Whether Component is locked from UI edits (required). * Code-first Components are always locked. */ locked: boolean; /** i18n: whether the component is localized (translatable fields in `comp__locales`). */ localized: boolean; /** * When the Component was created. * Auto-set by the database. */ createdAt: Date; /** * When the Component was last updated. * Auto-updated on each modification. */ updatedAt: Date; } /** * All supported Component source types. * * Useful for validation and iteration. * * @example * ```typescript * if (FIELD_GROUP_SOURCE_TYPES.includes(source)) { * // Valid source type * } * ``` */ declare const FIELD_GROUP_SOURCE_TYPES: readonly FieldGroupSource[]; /** * All supported Component migration statuses. * * Useful for validation and iteration. * * @example * ```typescript * if (FIELD_GROUP_MIGRATION_STATUSES.includes(status)) { * // Valid migration status * } * ``` */ declare const FIELD_GROUP_MIGRATION_STATUSES: readonly ["synced", "pending", "generated", "applied", "failed", "diverged"]; /** * Direct API Field Groups Type Definitions * * Type-safe field group slug resolution and argument types for the * `nextly.fieldGroups.*` namespace. * * @packageDocumentation */ /** * Field group slug type. * * When generated types exist, this resolves to a union of valid field group * slug literals (e.g., `'seo' | 'hero'`). Without generated types, * falls back to `string`. * * The key here MUST match the one `TypeGenerator` emits into `Config`. If the * two drift, this conditional silently takes the fallback branch and every slug * widens to `string` — no compile error anywhere, just lost type safety. * * The conditional is factored into `FieldGroupSlugFrom` so a test can apply it * to a stand-in for the generated types. Asserting against a locally re-declared * copy of the same conditional would pass even if THIS alias read the wrong key, * which is the failure being guarded. Pinned by * `__tests__/generated-config-contract.test.ts`. */ type FieldGroupSlugFrom = TGenerated extends { fieldGroups: infer C; } ? keyof C & string : string; type FieldGroupSlug = FieldGroupSlugFrom; /** * Resolves the field group type for a given field group slug. * * @typeParam TSlug - The field group slug string literal */ type DataFromFieldGroupSlugFrom = TGenerated extends { fieldGroups: infer C; } ? TSlug extends keyof C ? C[TSlug] : Record : Record; type DataFromFieldGroupSlug = DataFromFieldGroupSlugFrom; /** * Field group definition data returned by the Direct API. * * This is the metadata about a field group definition, not the instance data. * Instance data is automatically populated when reading collection/single entries * that have field group fields. */ interface FieldGroupDefinition { /** Unique identifier */ id: string; /** Field group slug */ slug: string; /** Display label */ label: string; /** Database table name (e.g., 'comp_seo') */ tableName: string; /** Optional description */ description?: string; /** Field configurations */ fields: Record[]; /** Admin UI configuration */ admin?: { /** Category for organizing field groups */ category?: string; /** Icon identifier */ icon?: string; /** Whether hidden from UI navigation */ hidden?: boolean; /** Description text */ description?: string; /** Preview image URL */ imageURL?: string; }; /** * Whether this field group stores translatable values per locale. * * `true` means its translatable columns live in `comp__locales` rather than on the main * table. Always present: the setting is a fact about the stored field group, and reporting * `undefined` for a non-localized one would make "not localized" indistinguishable from "this * client is too old to know", which is the distinction a caller comparing before and after a * toggle depends on. */ localized: boolean; /** Source of the field group definition */ source: "code" | "ui"; /** Whether the field group is locked (code-first field groups are locked) */ locked: boolean; /** Path to config file (code-first only) */ configPath?: string; /** Schema hash for change detection */ schemaHash: string; /** Schema version number */ schemaVersion: number; /** Migration status */ migrationStatus: FieldGroupMigrationStatus$1; /** Last applied migration ID */ lastMigrationId?: string; /** ID of user who created this field group */ createdBy?: string; /** Creation timestamp */ createdAt: Date; /** Last update timestamp */ updatedAt: Date; } /** * Arguments for finding field group definitions. * * @example * ```typescript * // List all field groups * const fieldGroups = await nextly.fieldGroups.find(); * * // List with filters * const uiFieldGroups = await nextly.fieldGroups.find({ * source: 'ui', * search: 'hero', * limit: 10, * }); * ``` */ interface FindFieldGroupsArgs extends DirectAPIConfig { /** Filter by source type */ source?: "code" | "ui"; /** * Filter by migration status. * * The SAME union the definition above carries, so a status this API can return is always a status * it can be asked for. Spelled out separately, the two drifted silently: a new status could come * back from `find()` while being impossible to filter on, with nothing to compile against. */ migrationStatus?: FieldGroupMigrationStatus$1; /** Include only locked or unlocked field groups */ locked?: boolean; /** Search query for filtering by slug or label */ search?: string; /** Maximum number of results */ limit?: number; /** Number of results to skip (for pagination) */ offset?: number; } /** * Arguments for finding a field group definition by slug. * * @example * ```typescript * const fieldGroup = await nextly.fieldGroups.findBySlug({ slug: 'seo' }); * if (fieldGroup) { * console.log('Fields:', fieldGroup.fields); * } * ``` */ interface FindFieldGroupBySlugArgs extends DirectAPIConfig { /** Field group slug (required) */ slug: string; } /** * Arguments for creating a field group definition. * * Only UI-created field groups can be created via the Direct API. * Code-first field groups are synced automatically (HMR listener or * `nextly db:sync`). * * @example * ```typescript * const fieldGroup = await nextly.fieldGroups.create({ * slug: 'testimonial', * label: 'Testimonial', * fields: [ * { type: 'text', name: 'quote', required: true }, * { type: 'text', name: 'author' }, * { type: 'upload', name: 'avatar', relationTo: 'media' }, * ], * admin: { * category: 'Blocks', * icon: 'Quote', * }, * }); * ``` */ interface CreateFieldGroupArgs extends DirectAPIConfig { /** Field group slug (required) */ slug: string; /** Display label (required) */ label: string; /** Field configurations (required) */ fields: Record[]; /** Optional description */ description?: string; /** Admin UI configuration */ admin?: { /** Category for organizing field groups */ category?: string; /** Icon identifier */ icon?: string; /** Whether hidden from UI navigation */ hidden?: boolean; /** Description text */ description?: string; /** Preview image URL */ imageURL?: string; }; } /** * Arguments for updating a field group definition. * * Code-first (locked) field groups cannot be updated via the Direct API. * * @example * ```typescript * const updated = await nextly.fieldGroups.update({ * slug: 'testimonial', * data: { * label: 'Customer Testimonial', * admin: { category: 'Social Proof' }, * }, * }); * ``` */ interface UpdateFieldGroupArgs extends DirectAPIConfig { /** Field group slug (required) */ slug: string; /** Update data */ data: { /** Updated display label */ label?: string; /** Updated description */ description?: string; /** Updated field configurations */ fields?: Record[]; /** * Whether this field group stores translatable values per locale. * * Omitted leaves the persisted setting alone. Changing it MOVES DATA: enabling seeds the * companion table from the main one and drops those columns, disabling restores and archives * them. Enabling requires the app's `localization` config, without which the tables would take * a shape the runtime cannot write to. */ localized?: boolean; /** Updated admin configuration */ admin?: { category?: string; icon?: string; hidden?: boolean; description?: string; imageURL?: string; }; }; } /** * Arguments for deleting a field group definition. * * Deletion will fail if: * - The field group is locked (code-first) * - Any collection, single, or other field group references this field group * * @example * ```typescript * const result = await nextly.fieldGroups.delete({ slug: 'testimonial' }); * console.log(result.message); // e.g. "Field group deleted." * console.log(result.item.slug); // "testimonial" * ``` */ interface DeleteFieldGroupArgs extends DirectAPIConfig { /** Field group slug (required) */ slug: string; } /** * Direct API Email Type Definitions * * Argument types for email providers, email templates, user field definitions, * and the email send API. * * @packageDocumentation */ /** * Arguments for finding email providers. */ interface FindEmailProvidersArgs extends DirectAPIConfig { /** Maximum providers per page */ limit?: number; /** Page number (1-indexed) */ page?: number; } /** * Arguments for finding an email provider by ID. */ interface FindEmailProviderByIDArgs extends DirectAPIConfig { /** Provider ID (required) */ id: string; /** Return `null` instead of throwing for not-found. @default false */ disableErrors?: boolean; } /** * Arguments for creating an email provider. */ interface CreateEmailProviderArgs extends DirectAPIConfig { /** Provider data (required) */ data: { /** Display name */ name: string; /** Provider type */ type: EmailProviderType; /** From email address */ fromEmail: string; /** From display name */ fromName?: string; /** Provider-specific configuration (credentials encrypted at rest) */ configuration: Record; /** Mark as default provider */ isDefault?: boolean; /** * Whether the provider may be selected to send. Defaults to true. * * Declared on the CREATE shape rather than only on the update, because * both services accept it and `UpdateEmailProviderArgs` derives its own * fields from this one. A typed caller could otherwise neither create a * provider deactivated nor deactivate one afterwards, while a JavaScript * caller could do both — the namespace forwards `data` unchanged, so the * capability was there and only the type withheld it. */ isActive?: boolean; }; } /** * Arguments for updating an email provider. */ interface UpdateEmailProviderArgs extends DirectAPIConfig { /** Provider ID (required) */ id: string; /** Partial provider data */ data: Partial & { /** * Configuration fields to REMOVE, by the name the provider declares. * * Not expressible through `Partial<...>` of the create shape, because a * create has nothing to remove. Without it a typed caller can set an * optional field but never clear one — a JavaScript caller could, since * the namespace forwards `data` unchanged, so the capability existed and * only the type withheld it. */ unsetConfiguration?: string[]; }; } /** * Arguments for deleting an email provider. */ interface DeleteEmailProviderArgs extends DirectAPIConfig { /** Provider ID (required) */ id: string; } /** * Arguments for setting an email provider as default. */ interface SetDefaultProviderArgs extends DirectAPIConfig { /** Provider ID (required) */ id: string; } /** * Arguments for sending a test email through a provider. */ interface TestEmailProviderArgs extends DirectAPIConfig { /** Provider ID (required) */ id: string; /** Recipient email address (required) */ to: string; /** * `"send"` (default) dispatches a real message to `to`, which is what the * admin's Send Test button promises. `"connection"` asks the provider's own * probe and sends nothing — available only where the descriptor reports * `capabilities.connectionTest`, and `to` is then ignored. */ mode?: "send" | "connection"; } /** * Arguments for finding email templates. */ interface FindEmailTemplatesArgs extends DirectAPIConfig { /** Maximum templates per page */ limit?: number; /** Page number (1-indexed) */ page?: number; } /** * Arguments for finding an email template by ID. */ interface FindEmailTemplateByIDArgs extends DirectAPIConfig { /** Template ID (required) */ id: string; /** Return `null` instead of throwing for not-found. @default false */ disableErrors?: boolean; } /** * Arguments for finding an email template by slug. */ interface FindEmailTemplateBySlugArgs extends DirectAPIConfig { /** Template slug (required) */ slug: string; /** Return `null` instead of throwing for not-found. @default false */ disableErrors?: boolean; } /** * Arguments for creating an email template. */ interface CreateEmailTemplateArgs extends DirectAPIConfig { /** Template data (required) */ data: { /** Display name */ name: string; /** Unique slug identifier */ slug: string; /** Email subject line (supports {{variable}} interpolation) */ subject: string; /** HTML content (supports {{variable}} interpolation) */ htmlContent: string; /** Plain text fallback content */ textContent?: string; /** Row kind: "template" (default), "layout", or "partial". */ kind?: "template" | "layout" | "partial"; /** Hidden inbox-preview line rendered before the body. */ preheader?: string; /** Layout row that wraps this template at its {{content}} marker. */ layoutId?: string; /** Override the provider's From address for this template. */ fromOverride?: string; /** Reply-To address for this template. */ replyTo?: string; /** Whether to wrap this template in its layout when sending. */ useLayout?: boolean; /** Specific provider ID to use for this template */ providerId?: string; /** Whether this template is active */ isActive?: boolean; /** Template variables metadata */ variables?: { name: string; description: string; required?: boolean; }[]; /** * Default attachments for this template. Merged with per-send * attachments at send time; per-send wins on mediaId conflict. */ attachments?: EmailAttachmentInput[]; }; } /** * Arguments for updating an email template. */ interface UpdateEmailTemplateArgs extends DirectAPIConfig { /** Template ID (required) */ id: string; /** Partial template data */ data: Partial; } /** * Arguments for deleting an email template. */ interface DeleteEmailTemplateArgs extends DirectAPIConfig { /** Template ID (required) */ id: string; } /** * Arguments for previewing an email template with variable data. */ interface PreviewEmailTemplateArgs extends DirectAPIConfig { /** Template ID (required) */ id: string; /** Variable values for interpolation */ data?: Record; } /** * Arguments for finding user field definitions. */ interface FindUserFieldsArgs extends DirectAPIConfig { /** Maximum fields per page */ limit?: number; /** Page number (1-indexed) */ page?: number; /** Include inactive (soft-deleted) fields. @default false */ includeInactive?: boolean; } /** * Arguments for finding a user field definition by ID. */ interface FindUserFieldByIDArgs extends DirectAPIConfig { /** Field definition ID (required) */ id: string; /** Return `null` instead of throwing for not-found. @default false */ disableErrors?: boolean; } /** * Arguments for creating a user field definition. * Only UI-sourced fields can be created via the Direct API. */ interface CreateUserFieldArgs extends DirectAPIConfig { /** Field definition data (required) */ data: { /** Unique field name (camelCase) */ name: string; /** Display label */ label: string; /** Field type */ type: "text" | "textarea" | "number" | "email" | "select" | "radio" | "checkbox" | "date"; /** Whether the field is required */ required?: boolean; /** Default value */ defaultValue?: string; /** Options for select/radio fields */ options?: { label: string; value: string; }[]; /** Placeholder text */ placeholder?: string; /** Help text / description */ description?: string; /** Display order */ sortOrder?: number; }; } /** * Arguments for updating a user field definition. * Code-first fields (`source: 'code'`) cannot be updated. */ interface UpdateUserFieldArgs extends DirectAPIConfig { /** Field definition ID (required) */ id: string; /** Partial field definition data */ data: Partial; } /** * Arguments for deleting a user field definition. * Code-first fields (`source: 'code'`) cannot be deleted. */ interface DeleteUserFieldArgs extends DirectAPIConfig { /** Field definition ID (required) */ id: string; } /** * Arguments for reordering user field definitions. */ interface ReorderUserFieldsArgs extends DirectAPIConfig { /** Ordered array of field definition IDs */ orderedIds: string[]; } /** * Arguments for sending a raw email. */ interface SendEmailArgs extends DirectAPIConfig { /** Recipient email address(es) */ to: string | string[]; /** Email subject line (required) */ subject: string; /** HTML content (required) */ html: string; /** Plain text fallback */ text?: string; /** Override the "from" address */ from?: string; /** CC recipients (carbon copy). */ cc?: string[]; /** BCC recipients (blind carbon copy). */ bcc?: string[]; /** Use a specific provider instead of the default */ providerId?: string; /** * Attachments sourced from the media library. * Each entry references a media record by ID; Nextly loads the bytes * from storage and forwards to the provider. */ attachments?: EmailAttachmentInput[]; } /** * Arguments for sending an email using a database template. */ interface SendTemplateEmailArgs extends DirectAPIConfig { /** Recipient email address(es) */ to: string | string[]; /** Template slug (required) */ template: string; /** Variables for template interpolation */ variables?: Record; /** Override the "from" address */ from?: string; /** CC recipients (carbon copy). */ cc?: string[]; /** BCC recipients (blind carbon copy). */ bcc?: string[]; /** Use a specific provider instead of the default */ providerId?: string; /** * Attachments sourced from the media library. Merged with the * template's default attachments at send-time (Phase 2). */ attachments?: EmailAttachmentInput[]; } /** * Result of an email send operation. */ interface SendEmailResult { /** Whether the email was sent successfully */ success: boolean; /** Provider-assigned message ID (on success) */ messageId?: string; /** Error message (on failure) */ error?: string; } /** * API Key Service * * Manages the full lifecycle of API keys — generation, hashing, CRUD, * validation, and permission resolution. Keys use a three-tier token type * model (Read-only / Full access / Role-based) backed by Nextly's RBAC * system as the single source of truth for permissions. * * ## Security Invariants * * - Raw keys are NEVER stored. Only a SHA-256 hex digest (`keyHash`) is * persisted. The full key is returned exactly once on creation and must * be surfaced to the caller immediately. * - Keys are cryptographically random (32 bytes = 256-bit entropy). * - The `nx_live_` prefix allows instant identification in logs/configs. * - Expiry is enforced at validation time; expired keys return `null` from * `authenticateApiKey()` and result in a `401` response. * * ## Key Format * * ``` * nx_live_ * └──────┘ └───────────────────┘ * prefix 43-char base64url secret (256 bits) * * Full key example : nx_live_... (51 chars total) * Stored hash : sha256(fullKey) as hex string (64 chars) * Display prefix : first 16 chars ("nx_live_abcdefgh") for masked UI display * ``` * * @module domains/auth/services/api-key-service * @since 1.0.0 */ /** The three token types that determine how permissions are resolved at request time. */ type ApiKeyTokenType = "read-only" | "full-access" | "role-based"; /** Token duration options for key expiry. "unlimited" means the key never expires. */ type ExpiresIn = "7d" | "30d" | "90d" | "unlimited"; /** * Metadata returned by all API key endpoints. * The raw key hash and secret are NEVER returned. */ interface ApiKeyMeta { id: string; name: string; description: string | null; /** First 16 chars of the key (e.g. "nx_live_abcdefgh") — for masked UI display. */ keyPrefix: string; tokenType: ApiKeyTokenType; /** Populated only for role-based keys. Null if no role is set or role was deleted. */ role: { id: string; name: string; slug: string; } | null; /** ISO 8601 string, or null for unlimited keys. */ expiresAt: string | null; /** ISO 8601 string of the last request that used this key, or null if never used. */ lastUsedAt: string | null; isActive: boolean; createdAt: string; updatedAt: string; } /** Input for creating a new API key. */ interface CreateApiKeyInput { /** Human-readable label, e.g. "Frontend App Key". 1–255 characters. */ name: string; /** Optional documentation about this key's intended use. */ description?: string | null; tokenType: ApiKeyTokenType; /** Required when tokenType is "role-based". Must be absent for other token types. */ roleId?: string | null; expiresIn: ExpiresIn; } /** Input for updating an existing API key. Only name and description can change. */ interface UpdateApiKeyInput { name?: string; description?: string | null; } declare class ApiKeyService extends BaseService { private apiKeysTable; private rolesTable; private userRolesTable; private rolePermissionsTable; private permissionsTable; constructor(adapter: DrizzleAdapter, logger: Logger); /** * Create a new API key for a user. * * The raw key is returned exactly once in the result. It is NOT stored and * cannot be retrieved again — the caller must surface it to the user immediately. * * For role-based keys, validates that the target role's permissions are a * subset of the creator's permissions (permission ceiling enforcement). * * @param userId - ID of the user creating the key * @param input - Key creation parameters * @returns Object containing the key metadata and the raw key string * * @throws NextlyError(VALIDATION_ERROR) if roleId is missing/extraneous for the token type * @throws NextlyError(FORBIDDEN) if the role's permissions exceed the creator's * @throws NextlyError via fromDatabaseError on DB constraint violations */ createApiKey(userId: string, input: CreateApiKeyInput): Promise<{ meta: ApiKeyMeta; key: string; }>; /** * List API keys for a user, ordered by creation date (newest first). * * @param userId - The requesting user's ID (used to filter keys when not allUsers) * @param opts.allUsers - When true, returns keys for all users (for super-admin callers) * @returns Array of key metadata (raw key and hash are never returned) */ listApiKeys(userId: string, opts?: { allUsers?: boolean; }): Promise; /** * Get a single API key by ID. * * @param id - The API key ID * @param userId - The requesting user's ID * @param opts.allUsers - When true, skips ownership check (for super-admin callers) * @returns Key metadata, or null if not found (or not owned by userId) */ getApiKeyById(id: string, userId: string, opts?: { allUsers?: boolean; }): Promise; /** * Update an API key's name and/or description. * * Token type, role, and duration cannot be changed after creation. * To change those fields, revoke the key and create a new one. * * Ownership is enforced — only the key's creator can update it. * * @param id - The API key ID * @param userId - The requesting user's ID (must be the key owner) * @param input - Fields to update (name, description) * @returns Updated key metadata * * @throws NextlyError(NOT_FOUND) if key doesn't exist or is not owned by userId */ updateApiKey(id: string, userId: string, input: UpdateApiKeyInput): Promise; /** * Revoke an API key by setting isActive = false (soft delete). * * The row is preserved for audit trail purposes. Revoked keys are * rejected at authentication time with a 401 response. * * Ownership is enforced — only the key's creator can revoke it. * * @param id - The API key ID * @param userId - The requesting user's ID (must be the key owner) * * @throws NextlyError(NOT_FOUND) if key doesn't exist or is not owned by userId */ revokeApiKey(id: string, userId: string): Promise; /** * Resolve the effective permission slugs for an authenticated API key. * * Called by auth middleware immediately after {@link authenticateApiKey} * to determine what actions the key is permitted to perform. Results are * cached in-memory for {@link PERMISSIONS_CACHE_TTL_MS} (5 min) keyed by * `"apikey:{keyId}"`. Call {@link invalidatePermissionsCache} to evict. * * Token type semantics: * - **read-only** — creator's full permission set, filtered to `read-*` slugs only * - **full-access** — creator's full permission set (all slugs) * - **role-based** — the assigned role's permission set. * If the role has been deleted (`roleId === null`), returns `[]` and logs a warning. * * @param tokenType - The key's token type * @param roleId - The assigned role ID (only relevant for "role-based" keys) * @param userId - The key creator's user ID * @param keyId - The API key's own ID (used for cache keying and log messages) * @returns Array of permission slugs (e.g. `["read-posts", "read-users"]`) * * @example * ```typescript * const auth = await apiKeyService.authenticateApiKey(rawKey); * if (!auth) return Response.json({ error: "Unauthorized" }, { status: 401 }); * const permissions = await apiKeyService.resolveApiKeyPermissions( * auth.tokenType, auth.roleId, auth.userId, auth.id * ); * // permissions → ["read-posts", "read-users", ...] * ``` */ resolveApiKeyPermissions(tokenType: ApiKeyTokenType, roleId: string | null, userId: string, keyId: string): Promise; /** * Evict a single API key's resolved permissions from the shared in-memory cache. * * Delegates to the module-level {@link invalidateApiKeyPermissionsCache} so that * callers who hold a service reference (e.g. REST handlers) can use either form. * * @param keyId - The API key ID whose cache entry should be evicted */ invalidatePermissionsCache(keyId: string): void; /** * Resolve the role slugs that apply to an authenticated API key. * * Called by auth middleware alongside {@link resolveApiKeyPermissions} to * populate `AuthContext.roles` for API key requests. This ensures that * code-defined access functions checking `ctx.roles.includes('editor')` * work identically for both session and API key auth. * * Token type semantics: * - **role-based** — `[selectedRole.slug]`. Single lookup by the assigned `roleId`. * If the role has been deleted (`roleId === null`), returns `[]`. * - **full-access / read-only** — creator's full assigned role slugs, resolved * via `listRoleSlugsForUser()`. Same set the user would see in a session context. * * @param tokenType - The key's token type * @param roleId - The assigned role ID (only relevant for "role-based" keys) * @param userId - The key creator's user ID * @returns Array of role slugs (e.g. `["editor"]` or `["super-admin", "editor"]`) */ resolveApiKeyRoles(tokenType: ApiKeyTokenType, roleId: string | null, userId: string): Promise; private resolveRolePermissionSlugs; private resolveUserPermissionSlugs; /** * Validate an incoming raw API key from a request header. * * Called by auth middleware on every request that presents a * `Authorization: Bearer nx_live_...` header. Designed for the hot path: * - Single SELECT with 5 columns, no JOIN * - Unique index hit on `keyHash` (O(1) lookup) * - Fire-and-forget `lastUsedAt` update (not awaited) * * Returns `null` for any failure case (not found, revoked, expired) so the * middleware can respond with a uniform 401 without leaking the reason. * * @param rawKey - The full raw key from the `Authorization: Bearer` header * @returns Auth tuple `{ id, userId, tokenType, roleId }` on success, or `null` * * @example * ```typescript * const result = await apiKeyService.authenticateApiKey(rawKey); * if (!result) return Response.json({ error: "Unauthorized" }, { status: 401 }); * const permissions = await apiKeyService.resolveApiKeyPermissions( * result.tokenType, result.roleId, result.userId, result.id * ); * ``` */ authenticateApiKey(rawKey: string): Promise<{ id: string; userId: string; tokenType: ApiKeyTokenType; roleId: string | null; } | null>; private toMeta; private resolveExpiresAt; private checkPermissionCeiling; } /** * Access Control Type Definitions * * This module provides type definitions for collection-level access control * that can be stored in the database (for UI-created collections) or defined * in code (for code-first collections). * * Access rules are evaluated at runtime by the AccessControlService to * determine whether a user can perform CRUD operations on a collection. * * @module services/access/types * @since 1.0.0 * * @example * ```typescript * import type { CollectionAccessRules, StoredAccessRule } from '@nextly/services/access'; * * // Public read, authenticated create, role-based update/delete * const accessRules: CollectionAccessRules = { * read: { type: 'public' }, * create: { type: 'authenticated' }, * update: { type: 'role-based', allowedRoles: ['admin', 'editor'] }, * delete: { type: 'role-based', allowedRoles: ['admin'] }, * }; * ``` */ /** * Predefined access rule types for UI collections. * * These types define how access is determined for each operation: * * - `public` - Anyone can access (no authentication required) * - `authenticated` - Only logged-in users can access * - `role-based` - Only users with specific roles can access (OR logic: any role matches) * - `owner-only` - Only the document owner can access (based on a field like `createdBy`) * - `custom` - Reference to a code-defined function (code-first collections only) * * @example * ```typescript * const ruleType: AccessRuleType = 'role-based'; * ``` */ type AccessRuleType = "public" | "authenticated" | "role-based" | "owner-only" | "custom"; /** * CRUD operations that can have access rules. * * These map to the four primary operations on a collection: * - `create` - Creating new documents * - `read` - Reading/listing documents * - `update` - Modifying existing documents * - `delete` - Removing documents * * @example * ```typescript * const operation: AccessOperation = 'read'; * ``` */ type AccessOperation = "create" | "read" | "update" | "delete" | "publish" | "unpublish"; /** * A storable access rule configuration. * * This interface defines the structure of an access rule that can be * serialized to JSON and stored in the database. Each property is * relevant to specific rule types: * * - `type` - Required for all rules * - `allowedRoles` - Required for `role-based` type (OR logic: user needs ANY of these roles) * - `ownerField` - Optional for `owner-only` type (defaults to `'createdBy'`) * - `functionPath` - Required for `custom` type (code-first only) * * @example * ```typescript * // Public access - anyone can access * const publicRule: StoredAccessRule = { type: 'public' }; * * // Authenticated access - logged-in users only * const authRule: StoredAccessRule = { type: 'authenticated' }; * * // Role-based access - admin OR editor can access * const roleRule: StoredAccessRule = { * type: 'role-based', * allowedRoles: ['admin', 'editor'], * }; * * // Owner-only access - only document owner can access * const ownerRule: StoredAccessRule = { * type: 'owner-only', * ownerField: 'authorId', // defaults to 'createdBy' if not specified * }; * * // Custom access - code-defined function (code-first only) * const customRule: StoredAccessRule = { * type: 'custom', * functionPath: '@/access/isAdmin', * }; * ``` */ interface StoredAccessRule { /** * The type of access rule. * Determines how access is evaluated. */ type: AccessRuleType; /** * Roles that are allowed access. * * Only used when `type` is `'role-based'`. * Uses OR logic: user needs ANY of these roles to access. * Role values should match the role slugs in your RBAC system * (e.g., `'admin'`, `'editor'`, `'user'`). * * @example ['admin', 'editor'] */ allowedRoles?: string[]; /** * Field name containing the document owner's user ID. * * Only used when `type` is `'owner-only'`. * The service compares the authenticated user's ID with the value * of this field to determine ownership. * * @default 'createdBy' * @example 'authorId' */ ownerField?: string; /** * Path to a custom access function. * * Only used when `type` is `'custom'`. * This is only supported for code-first collections where the * function can be imported and executed at runtime. * * The function should follow the access function signature: * `(args: { req, id?, data?, doc? }) => boolean | Promise` * * @example '@/access/isAdmin' * @example './access/canEditPosts' */ functionPath?: string; } /** * Complete access rules configuration for a collection. * * Defines access rules for all four CRUD operations. If a rule is not * specified for an operation, the default behavior is determined by * the AccessControlService (typically public access for backward compatibility). * * @example * ```typescript * // Blog posts: public read, authenticated create, owner can update/delete * const blogAccessRules: CollectionAccessRules = { * create: { type: 'authenticated' }, * read: { type: 'public' }, * update: { type: 'owner-only' }, * delete: { type: 'owner-only' }, * }; * * // Admin-only collection * const adminAccessRules: CollectionAccessRules = { * create: { type: 'role-based', allowedRoles: ['admin'] }, * read: { type: 'role-based', allowedRoles: ['admin'] }, * update: { type: 'role-based', allowedRoles: ['admin'] }, * delete: { type: 'role-based', allowedRoles: ['admin'] }, * }; * * // Mixed permissions * const contentAccessRules: CollectionAccessRules = { * create: { type: 'role-based', allowedRoles: ['admin', 'editor'] }, * read: { type: 'public' }, * update: { type: 'role-based', allowedRoles: ['admin', 'editor'] }, * delete: { type: 'role-based', allowedRoles: ['admin'] }, * }; * ``` */ interface CollectionAccessRules { /** * Access rule for creating new documents. * If not specified, defaults to public access. */ create?: StoredAccessRule; /** * Access rule for reading/listing documents. * If not specified, defaults to public access. * * For `owner-only` type, read operations return a query constraint * to filter documents by ownership instead of returning a boolean. */ read?: StoredAccessRule; /** * Access rule for updating existing documents. * If not specified, defaults to public access. */ update?: StoredAccessRule; /** * Access rule for deleting documents. * If not specified, defaults to public access. */ delete?: StoredAccessRule; /** * Access rule for making a document public (status → published). * If not specified, defaults to public access, so the RBAC * `publish-` permission is the only gate. */ publish?: StoredAccessRule; /** * Access rule for taking a document down (status → out of published). * If not specified, defaults to public access. */ unpublish?: StoredAccessRule; } /** * Result of evaluating an access rule. * * Used by AccessControlService to return the result of access evaluation. * Contains: * - `allowed` - Whether access is granted * - `query` - Optional query constraint for filtering (used with `owner-only` read) * - `reason` - Optional explanation for denial (useful for debugging/logging) * * @example * ```typescript * // Access granted * const allowed: AccessEvaluationResult = { allowed: true }; * * // Access denied with reason * const denied: AccessEvaluationResult = { * allowed: false, * reason: 'Authentication required', * }; * * // Access granted with query constraint (owner-only read) * const filtered: AccessEvaluationResult = { * allowed: true, * query: { createdBy: { equals: 'user-123' } }, * }; * ``` */ interface AccessEvaluationResult { /** * Whether access is allowed. */ allowed: boolean; /** * Optional query constraint for filtering documents. * * Used primarily for `owner-only` read operations where the service * needs to filter documents by ownership rather than deny access entirely. * The query follows the Nextly Where query format. */ query?: Record; /** * Optional reason for denial. * * Populated when `allowed` is `false` to provide context for * logging, debugging, or user-facing error messages. */ reason?: string; } /** * Direct API RBAC Type Definitions * * Roles, permissions, access checks, and API key entity/argument types. * * @packageDocumentation */ /** * Role document returned by the Direct API. * * A role groups a set of permissions and can be assigned to users. * System roles (e.g., `super_admin`) are created on init and cannot be deleted. */ interface Role { /** Unique identifier */ id: string; /** Display name */ name: string; /** URL-safe identifier (e.g., `"super-admin"`) */ slug: string; /** Optional description */ description?: string | null; /** * Hierarchy level for role ordering. * Higher values indicate higher privilege levels. */ level: number; /** Whether this role is a built-in system role */ isSystem: boolean; } /** * Permission document returned by the Direct API. * * A permission represents a specific action that can be performed on a resource. * Auto-generated permissions follow the pattern `{action}-{resource}` (e.g., `read-posts`). */ interface Permission { /** Unique identifier */ id: string; /** Display name (e.g., `"Read Posts"`) */ name: string; /** URL-safe identifier (e.g., `"read-posts"`) */ slug: string; /** The action being permitted (`"create"`, `"read"`, `"update"`, `"delete"`, `"manage"`) */ action: string; /** The resource being protected (collection slug or system resource) */ resource: string; /** Optional description */ description?: string | null; } /** * Arguments for finding multiple roles. * * @example * ```typescript * const roles = await nextly.roles.find({ limit: 20, page: 1 }); * ``` */ interface FindRolesArgs extends DirectAPIConfig { /** Search by name or slug */ search?: string; /** Maximum roles per page. @default 10 */ limit?: number; /** Page number (1-indexed). @default 1 */ page?: number; } /** * Arguments for finding a role by ID. * * @example * ```typescript * const role = await nextly.roles.findByID({ id: 'role-123' }); * ``` */ interface FindRoleByIDArgs extends DirectAPIConfig { /** Role ID (required) */ id: string; } /** * Arguments for creating a new role. * * @example * ```typescript * const role = await nextly.roles.create({ * data: { * name: 'Editor', * slug: 'editor', * description: 'Can create and update content', * level: 10, * }, * }); * ``` */ interface CreateRoleArgs extends DirectAPIConfig { /** Role data (required) */ data: { /** Display name (required) */ name: string; /** URL-safe identifier (required, e.g., `"editor"`) */ slug: string; /** Optional description */ description?: string; /** * Hierarchy level for role ordering. * @default 0 */ level?: number; /** * Permission IDs to grant this role at creation. * * The role mutation service requires at least one permission OR at * least two child roles. Pass an empty array / omit only when you * also pass `childRoleIds` with two or more entries - otherwise the * create call will fail with `"At least one permission is required * to create a role"`. */ permissionIds?: string[]; /** * Child role IDs this role inherits from. See `permissionIds` for * the validation rule around the two fields. */ childRoleIds?: string[]; }; } /** * Arguments for updating a role. * * System roles (`isSystem: true`) cannot be deleted but can be updated * with restrictions (e.g., slug changes may be blocked). * * @example * ```typescript * const updated = await nextly.roles.update({ * id: 'role-123', * data: { description: 'Updated description', level: 20 }, * }); * ``` */ interface UpdateRoleArgs extends DirectAPIConfig { /** Role ID (required) */ id: string; /** Partial role data */ data: { /** Updated display name */ name?: string; /** Updated slug */ slug?: string; /** Updated description (`null` to clear) */ description?: string | null; /** Updated hierarchy level */ level?: number; }; } /** * Arguments for deleting a role. * * System roles (`isSystem: true`) cannot be deleted. * * @example * ```typescript * await nextly.roles.delete({ id: 'role-123' }); * ``` */ interface DeleteRoleArgs extends DirectAPIConfig { /** Role ID (required) */ id: string; } /** * Arguments for retrieving permissions assigned to a role. * * @example * ```typescript * const permissions = await nextly.roles.getPermissions({ id: 'role-123' }); * console.log(permissions); // Permission[] * ``` */ interface GetRolePermissionsArgs extends DirectAPIConfig { /** Role ID (required) */ id: string; } /** * Arguments for bulk-replacing the permissions assigned to a role. * * This is a **full replace** — all existing role-permission assignments are * removed and replaced with the provided list. Pass an empty array to clear * all permissions from the role. * * @example * ```typescript * // Replace all role permissions with a new set * await nextly.roles.setPermissions({ * roleId: 'role-123', * permissionIds: ['perm-1', 'perm-2', 'perm-3'], * }); * * // Clear all permissions from a role * await nextly.roles.setPermissions({ * roleId: 'role-123', * permissionIds: [], * }); * ``` */ interface SetRolePermissionsArgs extends DirectAPIConfig { /** Role ID (required) */ roleId: string; /** Ordered list of permission IDs to assign (replaces all existing) */ permissionIds: string[]; } /** * Arguments for finding multiple permissions. * * @example * ```typescript * // List all permissions for a specific resource * const permissions = await nextly.permissions.find({ resource: 'posts' }); * * // List all delete permissions * const deletePerms = await nextly.permissions.find({ action: 'delete' }); * ``` */ interface FindPermissionsArgs extends DirectAPIConfig { /** Search by name or slug */ search?: string; /** Filter by resource slug (e.g., `"posts"`, `"users"`) */ resource?: string; /** Filter by action (e.g., `"create"`, `"read"`, `"update"`, `"delete"`, `"manage"`) */ action?: string; /** Maximum permissions per page. @default 10 */ limit?: number; /** Page number (1-indexed). @default 1 */ page?: number; } /** * Arguments for finding a permission by ID. * * @example * ```typescript * const perm = await nextly.permissions.findByID({ id: 'perm-123' }); * ``` */ interface FindPermissionByIDArgs extends DirectAPIConfig { /** Permission ID (required) */ id: string; } /** * Arguments for creating a permission. * * In most cases, permissions are auto-generated when collections are created. * Use this for custom permissions not tied to a standard CRUD flow. * * @example * ```typescript * const perm = await nextly.permissions.create({ * data: { * name: 'Publish Posts', * slug: 'publish-posts', * action: 'update', * resource: 'posts', * description: 'Ability to publish draft posts', * }, * }); * ``` */ interface CreatePermissionArgs extends DirectAPIConfig { /** Permission data (required) */ data: { /** Display name (required, e.g., `"Read Posts"`) */ name: string; /** URL-safe identifier (required, e.g., `"read-posts"`) */ slug: string; /** Action being permitted (required) */ action: string; /** Resource being protected (collection slug or system resource) (required) */ resource: string; /** Optional description */ description?: string; }; } /** * Arguments for deleting a permission. * * System permissions (permissions whose resource is a system resource like * `"users"`, `"roles"`, `"settings"`) cannot be deleted. * * @example * ```typescript * await nextly.permissions.delete({ id: 'perm-123' }); * ``` */ interface DeletePermissionArgs extends DirectAPIConfig { /** Permission ID (required) */ id: string; } /** * Arguments for programmatically checking whether a user has access to perform * an operation on a resource. * * Evaluates the full three-tier access chain: * 1. Super-admin bypass (always allowed) * 2. Code-defined access functions from `defineCollection({ access: {...} })` / `defineSingle({ access: {...} })` * 3. Database RBAC permission check (role → permissions) * * @example * ```typescript * // Check if a user can read posts * const canRead = await nextly.access.check({ * userId: 'user-123', * resource: 'posts', * operation: 'read', * }); * * if (!canRead) { * throw new Error('Access denied'); * } * ``` */ interface CheckAccessArgs { /** ID of the user to check access for (required) */ userId: string; /** * The resource to check access on. * * Can be a collection slug (e.g., `"posts"`), a single slug (e.g., `"site-settings"`), * or a system resource (e.g., `"users"`, `"roles"`, `"settings"`). */ resource: string; /** The operation to check */ operation: AccessOperation; } /** * The result type returned by the Direct API for API key operations. * * Extends `ApiKeyMeta` with an optional `key` field that is ONLY present * when creating a new key. The raw key is shown once and never stored — * surface it to the user immediately. */ type ApiKeyResult = ApiKeyMeta & { /** * The full raw key value (e.g., `"nx_live_..."`). * * Only present on `apiKeys.create()` responses. * This is the only time the raw key is returned — store it safely. */ key?: string; }; /** * Arguments for listing API keys. * * @example * ```typescript * // List all keys for a specific user * const keys = await nextly.apiKeys.list({ userId: 'user-123' }); * * // List all keys across all users (server-side / super-admin mode) * const allKeys = await nextly.apiKeys.list(); * ``` */ interface ListApiKeysArgs extends DirectAPIConfig { /** Filter by owner user ID. Omit to list all keys (server-side / super-admin mode). */ userId?: string; /** Maximum keys per page. @default 10 */ limit?: number; /** Page number (1-indexed). @default 1 */ page?: number; } /** * Arguments for finding a single API key by ID. * * @example * ```typescript * const key = await nextly.apiKeys.findByID({ id: 'key-123' }); * ``` */ interface FindApiKeyByIDArgs extends DirectAPIConfig { /** API key ID (required) */ id: string; } /** * Arguments for creating a new API key. * * The raw key is returned in the `key` field of the result exactly once. * It is NOT stored and cannot be retrieved again. * * @example * ```typescript * const { doc, key } = await nextly.apiKeys.create({ * userId: 'user-123', * name: 'Frontend Integration', * tokenType: 'read-only', * expiresIn: '90d', * }); * // key → "nx_live_..." (show to user once, then discard) * ``` */ interface CreateApiKeyArgs extends DirectAPIConfig { /** ID of the user who will own this key (required). */ userId: string; /** Human-readable label for the key (required). */ name: string; /** Optional description of the key's intended use. */ description?: string | null; /** Token type that controls permission resolution. */ tokenType: ApiKeyTokenType; /** * Role ID for role-based keys. * * Required when `tokenType` is `"role-based"`. Must be absent for other token types. */ roleId?: string | null; /** How long until the key expires. Use `"unlimited"` for keys that never expire. */ expiresIn: ExpiresIn; } /** * Arguments for updating an API key's metadata. * * Only `name` and `description` can be changed after creation. * To change token type, role, or duration, revoke and create a new key. * * @example * ```typescript * const updated = await nextly.apiKeys.update({ * id: 'key-123', * name: 'Renamed Key', * }); * ``` */ interface UpdateApiKeyArgs extends DirectAPIConfig { /** API key ID (required) */ id: string; /** New display name */ name?: string; /** New description (`null` to clear) */ description?: string | null; } /** * Arguments for revoking (soft-deleting) an API key. * * Revoked keys immediately stop working for authentication. * The key record is retained in the database with `isActive: false`. * * @example * ```typescript * await nextly.apiKeys.revoke({ id: 'key-123' }); * ``` */ interface RevokeApiKeyArgs extends DirectAPIConfig { /** API key ID (required) */ id: string; } /** * Arguments for programmatically validating an API key. * * @example * ```typescript * const result = await nextly.access.checkApiKey({ rawKey: 'nx_live_...' }); * if (!result.valid) return Response.json({ error: 'Unauthorized' }, { status: 401 }); * ``` */ interface CheckApiKeyArgs { /** The raw API key value from the Authorization header (without `"Bearer "` prefix). */ rawKey: string; } /** * Result of an API key validation check. * * When `valid` is `false`, all other fields are absent. * Never throws — returns `{ valid: false }` for invalid, expired, or revoked keys. * * @example * ```typescript * const { valid, userId, permissions } = await nextly.access.checkApiKey({ rawKey }); * * if (!valid) { * return Response.json({ error: 'Unauthorized' }, { status: 401 }); * } * // userId, permissions, roles are populated for valid keys * ``` */ interface CheckApiKeyResult { /** Whether the key is valid and active. */ valid: boolean; /** ID of the user who owns the key. Present when `valid` is `true`. */ userId?: string; /** Token type of the key. Present when `valid` is `true`. */ tokenType?: ApiKeyTokenType; /** Resolved permission slugs (e.g., `["read-posts", "create-posts"]`). Present when `valid` is `true`. */ permissions?: string[]; /** Resolved role slugs for the key. Present when `valid` is `true` and roles are assigned. */ roles?: string[]; /** ISO 8601 expiry date, or `null` for unlimited keys. Present when `valid` is `true`. */ expiresAt?: string | null; } /** * PermissionService handles all permission CRUD operations. * * Responsibilities: * - List permissions with pagination and filtering * - Create, read, update, delete permissions * - Validate permission uniqueness (action + resource) * - Ensure permissions exist idempotently * * @example * ```typescript * const permissionService = new PermissionService(adapter, logger); * const result = await permissionService.listPermissions({ action: 'read' }); * ``` */ declare class PermissionService extends BaseService { constructor(adapter: DrizzleAdapter, logger: Logger); private validateResource; /** * List all permissions with pagination, search, and filtering. * * @param options - Pagination, search, filter, and sort options * @returns Paginated list of permissions with metadata */ listPermissions(options?: { page?: number; limit?: number; search?: string; action?: string; resource?: string; sortBy?: "action" | "resource" | "name"; sortOrder?: "asc" | "desc"; /** * Include permissions nothing declares any more. Off by default: they are * not a choice anyone should be offered. The cleanup that retires them * needs to see them, and asks. */ includeOrphaned?: boolean; }): Promise<{ data: Array<{ id: string; name: string; slug: string; action: string; resource: string; description: string | null; category?: string; /** Package that declared this permission; null for the built-in seeds. */ owner: string | null; /** True once the declaring package stopped declaring it. */ orphaned: boolean; /** Heading within the owner's section; null when the owner set none. */ group: string | null; /** True for a permission the admin should warn before granting. */ danger: boolean; }>; meta: { total: number; page: number; limit: number; totalPages: number; }; }>; /** * Get a permission by ID. * * @param permissionId - Permission ID * @returns Permission details * @throws NextlyError(NOT_FOUND) when no permission has this id, or it is * one of the hidden internal permissions (resource = 'permissions', or * create/delete on 'settings'). The hidden case maps to NOT_FOUND * intentionally — exposing it as FORBIDDEN would leak the policy. */ getPermissionById(permissionId: string): Promise<{ id: string; name: string; slug: string; action: string; resource: string; description: string | null; category?: string; }>; /** * Ensure a permission exists (idempotent create). * * Creates a permission if it doesn't exist. If a permission with the same * action and resource already exists, returns the existing permission ID. * * @param action - Permission action (e.g., 'read', 'write', 'delete') * @param resource - Permission resource (e.g., 'users', 'posts', 'settings') * @param name - Human-readable permission name * @param slug - URL-friendly permission slug * @param description - Optional permission description * @returns Permission ID (existing or newly created) */ ensurePermission(action: string, resource: string, name: string, slug: string, description?: string, /** * What the declaration says about the permission beyond its identity. * * An object rather than three more positional arguments: the signature was * already six deep, and `(…, undefined, undefined, true)` is not something * anyone should have to read. */ meta?: { /** * Who declared it — a plugin name, or omitted for the framework's own * per-collection seeds. Recorded so the admin can tell a plugin's custom * permission from a content type's, rather than inferring one from the * slug and inventing a collection that does not exist. */ owner?: string; /** Heading within the owner's section of the matrix. */ group?: string; /** True for a permission the admin should warn before granting. */ danger?: boolean; }): Promise<{ /** ID of the existing or newly created permission row. */ id: string; /** True if this call inserted a new row, false if a matching row already existed. */ created: boolean; }>; /** * Update a permission's name, action, resource, or description. * * Note: Changing action/resource may affect existing role-permission assignments. * * @param permissionId - Permission ID * @param changes - Fields to update * @returns Success/failure status */ updatePermission(permissionId: string, changes: { name?: string; slug?: string; action?: string; resource?: string; description?: string; }): Promise; /** * Delete a permission by ID if it's not assigned to any roles. * * @param permissionId - Permission ID * @throws NextlyError(NOT_FOUND) when no permission has this id. * @throws NextlyError(FORBIDDEN) when the permission belongs to a system * resource (system permissions are immutable). * @throws NextlyError(BUSINESS_RULE_VIOLATION) when the permission is * currently assigned to one or more roles. */ deletePermissionById(permissionId: string): Promise; /** * Delete a permission by action and resource if it's not assigned to any roles. * * @param action - Permission action * @param resource - Permission resource * @throws NextlyError(NOT_FOUND) when no permission matches. * @throws NextlyError(BUSINESS_RULE_VIOLATION) when the permission is in use. */ deletePermission(action: string, resource: string): Promise; } /** * RBAC Access Control Service * * Unified access control evaluation that merges code-defined access functions * (from `defineCollection({ access })` / `defineSingle({ access })`) with * database role/permission checks. * * Evaluation priority: * 1. **Super-admin bypass** — always returns `true` * 2. **Code-defined access** — function or boolean from collection/single config * 3. **Database permission** — checks RBAC tables via `hasPermission()` * * Default behavior: **deny** (fail-secure). If no code access is defined and * the user has no database permission for the resource+action, access is denied. * * Code-defined access configs are registered at startup via `registerCollectionAccess()` * and `registerSingleAccess()`. The service auto-resolves them during `checkAccess()` * when no explicit `codeAccess` parameter is provided. * * @module domains/auth/services/rbac-access-control-service * @since 1.0.0 * * @example * ```typescript * const rbac = new RBACAccessControlService(); * * // Register code-defined access at startup * rbac.registerCollectionAccess('posts', { * create: ({ roles }) => roles.includes('editor'), * read: true, * }); * * // Simple check — auto-resolves registered access, then DB permissions * const canRead = await rbac.checkAccess({ * userId: 'user-123', * operation: 'read', * resource: 'posts', * }); * ``` */ /** * Unified RBAC access control service. * * Orchestrates the three-tier access evaluation: * 1. Super-admin bypass * 2. Code-defined access functions/booleans (from in-memory registry or explicit param) * 3. Database role/permission checks * * Holds an in-memory registry of code-defined access configs registered * at startup from `defineCollection({ access })` and `defineSingle({ access })`. */ declare class RBACAccessControlService { private readonly collectionAccessMap; private readonly singleAccessMap; /** * Register code-defined access control for a collection. * Called during `syncCodeFirstCollections()` for each collection * that has an `access` property in its config. * * @param slug - The collection slug * @param access - The access control config from `defineCollection()` */ registerCollectionAccess(slug: string, access: CollectionAccessControl): void; /** * Register code-defined access control for a single. * Called during `syncCodeFirstSingles()` for each single * that has an `access` property in its config. * * @param slug - The single slug * @param access - The access control config from `defineSingle()` */ registerSingleAccess(slug: string, access: SingleAccessControl): void; /** * Get the registered code-defined access for a resource. * Checks collection map first, then single map. * * @param slug - The collection or single slug * @returns The registered access config, or `undefined` if none registered */ getRegisteredAccess(slug: string): CollectionAccessControl | SingleAccessControl | undefined; /** * Clear all registered access configs. * Useful for re-sync scenarios (e.g., watch-mode re-sync in dev). */ clearRegisteredAccess(): void; /** * Check if a user is allowed to perform an operation on a resource. * * If no explicit `codeAccess` is provided, auto-resolves from the * in-memory registry (populated at startup from `defineCollection`/`defineSingle`). * * @param params - Access check parameters * @returns `true` if access is allowed, `false` if denied * * @example * ```typescript * const allowed = await rbac.checkAccess({ * userId: 'user-123', * operation: 'update', * resource: 'posts', * }); * ``` */ checkAccess(params: CheckAccessParams): Promise; /** * Build the full access control context for code-defined functions. * * Resolves role slugs and effective permissions from the database. * This is only called when a code-defined access function needs * the full context — simple boolean checks and DB permission * fallbacks skip this entirely. * * @param userId - The authenticated user's ID * @param operation - The CRUD operation * @param resource - The collection/single slug * @returns Fully resolved AccessControlContext */ buildContext(userId: string, operation: AccessOperation, resource: string, executor?: unknown): Promise; } /** * RolePermissionService handles role-permission relationship management. * * Responsibilities: * - Assign permissions to roles * - Remove permissions from roles * - List all permissions for a role * - Invalidate permission cache on changes * * @example * ```typescript * const service = new RolePermissionService(adapter, logger); * await service.addPermissionToRole(roleId, { action: 'read', resource: 'users' }); * ``` */ declare class RolePermissionService extends BaseService { constructor(adapter: DrizzleAdapter, logger: Logger); /** * Add a permission to a role. * * This method wraps both permission creation (if needed) and role-permission assignment * in a transaction to ensure atomicity. If any operation fails, all changes are rolled back. * * Note: This method requires PermissionService to ensure permission exists. * Currently calls ensurePermission directly - will be refactored for composition. * * @param roleId - Role ID to assign permission to * @param perm - Permission specification (action, resource, optional name/slug) * @returns void */ addPermissionToRole(roleId: string, perm: { action: string; resource: string; name?: string; slug?: string; }): Promise; /** * Bring a row written by the reversed composition back onto the convention. * * An install upgraded from a version that composed `resource-action` still * holds those rows, and creating-if-missing never reaches them: identity is * `(action, resource)`, so the lookup finds the row and the corrected * composition is simply not used. The permission stays one that no * authorization check can resolve — which is the original bug, surviving the * fix. * * Renaming is safe here for the reason `ensurePermission` gives for doing the * same thing: a slug is a label rather than a key, grants reference the row * by id, so bringing a stale one into line renames without revoking. * * Deliberately narrow. It repairs ONLY a slug that is exactly the reversed * composition, and only where the caller supplied none of its own — so a * deliberately custom slug (`manage-api-keys` on action `update`, say) is * left alone rather than renamed to something its declarer never chose. */ private healReversedSlug; /** * Remove a permission from a role. * * @param roleId - Role ID to remove permission from * @param perm - Permission specification (action, resource) * @returns void */ removePermissionFromRole(roleId: string, perm: { action: string; resource: string; }): Promise; /** * Bulk-set (replace) all permissions for a role. * * Deletes all existing role-permission assignments for the role, then inserts * new assignments for each provided permission ID. This is an atomic replacement: * the caller passes the desired final set of permission IDs. * * @param roleId - Role ID to set permissions for * @param permissionIds - The complete desired set of permission IDs * @returns Updated array of permission objects with id, action, resource */ setRolePermissions(roleId: string, permissionIds: string[]): Promise>; /** * List all permissions assigned to a role. * * @param roleId - Role ID to list permissions for * @returns Array of permission objects with id, action, resource */ listRolePermissions(roleId: string): Promise>; } /** * RoleMutationService handles all role create/update/delete operations. * * Responsibilities: * - Create roles with permissions and child roles * - Update roles * - Delete roles with cascade * - Ensure system roles exist * * @example * ```typescript * const mutationService = new RoleMutationService(adapter, logger); * const result = await mutationService.createRole({ name: 'Editor', ... }); * ``` */ declare class RoleMutationService extends BaseService { /** * Creates a new RoleMutationService instance. * * @param adapter - Database adapter * @param logger - Logger instance */ constructor(adapter: DrizzleAdapter, logger: Logger); /** * Find role ID by slug (internal helper). * * @param slug - The role slug to search for * @returns Role ID or null if not found */ private findRoleIdBySlug; /** * Ensure super admin role exists (idempotent). * * @returns Role ID and whether it was newly created */ ensureSuperAdminRole(): Promise<{ id: string; created: boolean; }>; /** * Create a new role with permissions and child roles. * * This method wraps all database mutations in a transaction to ensure atomicity. * If any operation fails, all changes are rolled back. * * KNOWN LIMITATIONS: * 1. SQLite Transactions: For SQLite, transaction support is limited due to * synchronous callback requirements. Falls back to sequential execution. * * 2. Cross-Service Dependencies: This method has tight coupling with: * - PermissionService (permission validation) * - RolePermissionService (permission assignments) * - RoleInheritanceService (child role relationships) * * Future work: Extract to orchestrator service with proper transaction context. * * @param input - Role data including permissions and child roles * @returns Created role data */ createRole(input: { name: string; slug: string; description?: string; level?: number; isSystem?: boolean; permissionIds: string[]; childRoleIds?: string[]; }): Promise<{ id: string; name: string; slug: string; description: string | null; level: number; isSystem: boolean; permissionIds: string[]; childRoleIds: string[]; }>; /** * Update an existing role. * * This method wraps all database mutations in a transaction to ensure atomicity. * If any operation fails, all changes are rolled back. * * Note: System roles cannot be modified. * This method temporarily includes permission/child role management. * Will be refactored to use RolePermissionService and RoleInheritanceService. * * @param roleId - The role ID to update * @param changes - Fields to update * @returns Success/failure status */ updateRole(roleId: string, changes: { name?: string; slug?: string; description?: string; level?: number; permissionIds?: string[]; childRoleIds?: string[]; }): Promise; /** * Delete a role and cascade delete related data. * * This method wraps all database mutations in a transaction to ensure atomicity. * If any operation fails, all changes are rolled back. * * Note: System roles cannot be deleted. * * @param roleId - The role ID to delete * @returns Success/failure status */ deleteRole(roleId: string): Promise; } /** * RoleQueryService handles all role read/query operations. * * Responsibilities: * - List roles with pagination and filtering * - Get role by ID * - Find role by name or slug * * @example * ```typescript * const queryService = new RoleQueryService(adapter, logger); * const result = await queryService.listRoles({ page: 1, limit: 10 }); * ``` */ declare class RoleQueryService extends BaseService { /** * Creates a new RoleQueryService instance. * * @param adapter - Database adapter * @param logger - Logger instance */ constructor(adapter: DrizzleAdapter, logger: Logger); /** * List all roles with pagination, search, and filtering. * * @param options - Pagination, search, filter, and sort options * @returns Paginated list of roles with metadata */ listRoles(options?: { page?: number; limit?: number; search?: string; isSystem?: boolean; levelMin?: number; levelMax?: number; sortBy?: "name" | "level"; sortOrder?: "asc" | "desc"; includePermissions?: boolean; }): Promise<{ data: Array<{ id: string; name: string; description: string | null; level: number; isSystem: boolean; }>; meta: { total: number; page: number; limit: number; totalPages: number; }; }>; /** * Get a single role by ID. * * @param roleId - The role ID to fetch * @returns Role data or null if not found */ getRoleById(roleId: string): Promise<{ id: string; name: string; slug: string; description: string | null; level: number; isSystem: boolean; }>; /** * Find role by name. * * @param name - The role name to search for * @returns Role ID or null if not found */ getRoleByName(name: string): Promise<{ id: string; } | null>; /** * Find role ID by slug. * * @param slug - The role slug to search for * @returns Role ID or null if not found */ findRoleIdBySlug(slug: string): Promise<{ id: string; } | null>; } /** * RoleService handles all role CRUD operations. * * This is a facade that delegates to specialized services: * - RoleQueryService: Read operations (list, get, find) * - RoleMutationService: Write operations (create, update, delete) * * The facade maintains backward compatibility with existing code * that uses RoleService directly. * * Responsibilities: * - List roles with pagination and filtering * - Create, read, update, delete roles * - Ensure system roles exist (e.g., super-admin) * - Validate role uniqueness (name, slug) * * @example * ```typescript * const roleService = new RoleService(adapter, logger); * const result = await roleService.listRoles({ page: 1, limit: 10 }); * ``` */ declare class RoleService extends BaseService { constructor(adapter: DrizzleAdapter, logger: Logger); private _queryService?; private _mutationService?; private get queryService(); private get mutationService(); /** * Get the underlying query service for direct access. */ getQueryService(): RoleQueryService; /** * Get the underlying mutation service for direct access. */ getMutationService(): RoleMutationService; /** * List all roles with pagination, search, and filtering. * * @param options - Pagination, search, filter, and sort options * @returns Paginated list of roles with metadata * @throws NextlyError on DB errors. */ listRoles(options?: { page?: number; limit?: number; search?: string; isSystem?: boolean; levelMin?: number; levelMax?: number; sortBy?: "name" | "level"; sortOrder?: "asc" | "desc"; includePermissions?: boolean; }): Promise<{ data: Array<{ id: string; name: string; description: string | null; level: number; isSystem: boolean; }>; meta: { total: number; page: number; limit: number; totalPages: number; }; }>; /** * Get a single role by ID. * * @param roleId - The role ID to fetch * @returns Role data * @throws NextlyError(NOT_FOUND) when no role has this id. * @throws NextlyError(VALIDATION_ERROR) on a malformed roleId. */ getRoleById(roleId: string): Promise<{ id: string; name: string; slug: string; description: string | null; level: number; isSystem: boolean; }>; /** * Find role by name. * * @param name - The role name to search for * @returns Role ID or null if not found */ getRoleByName(name: string): Promise<{ id: string; } | null>; /** * Find role ID by slug. * * @param slug - The role slug to search for * @returns Role ID or null if not found */ findRoleIdBySlug(slug: string): Promise<{ id: string; } | null>; /** * Ensure super admin role exists (idempotent). * * @returns Role ID and whether it was newly created */ ensureSuperAdminRole(): Promise<{ id: string; created: boolean; }>; /** * Create a new role with permissions and child roles. * * @param input - Role data including permissions and child roles * @returns Created role data * @throws NextlyError(VALIDATION_ERROR) on missing/invalid permission or * child-role inputs. * @throws NextlyError(DUPLICATE) when name/slug collide with an existing role. */ createRole(input: { name: string; slug: string; description?: string; level?: number; isSystem?: boolean; permissionIds: string[]; childRoleIds?: string[]; }): Promise<{ id: string; name: string; slug: string; description: string | null; level: number; isSystem: boolean; permissionIds: string[]; childRoleIds: string[]; }>; /** * Update an existing role. * * @param roleId - The role ID to update * @param changes - Fields to update * @throws NextlyError(NOT_FOUND) if no role has this id. * @throws NextlyError(FORBIDDEN) if the role is a system role. */ updateRole(roleId: string, changes: { name?: string; slug?: string; description?: string; level?: number; permissionIds?: string[]; childRoleIds?: string[]; }): Promise; /** * Delete a role and cascade delete related data. * * @param roleId - The role ID to delete * @throws NextlyError(NOT_FOUND) if no role has this id. * @throws NextlyError(FORBIDDEN) if the role is a system role. */ deleteRole(roleId: string): Promise; } /** * How long the two audit trails are kept. * * Separate from webhook retention on purpose. The webhook windows bound a * delivery ledger and are chosen from how long a redelivery stays useful; these * bound a record of who did what, and are chosen from how long that is worth * answering questions with. Putting them under one key would also put an * operator's activity-retention setting somewhere no operator would look. * * @module domains/audit/retention-config * @since 1.0.0 */ /** `false` means keep forever and accept the growth. */ type MaxAge = number | false; interface AuditRetentionConfig { /** Content activity — who changed what. Default 90 days. */ activityMaxAgeMs?: MaxAge; /** Sign-ins, password changes, role grants. Default 180 days. */ authMaxAgeMs?: MaxAge; /** Shortest time between two passes. Default one hour. */ intervalMs?: number; /** Batches per pass. Default 20. */ maxBatchesPerRun?: number; } interface ResolvedAuditRetentionConfig { activityMaxAgeMs: MaxAge; authMaxAgeMs: MaxAge; intervalMs: number; maxBatchesPerRun: number; } /** A custom permission resolved to its concrete, seedable shape. */ interface CollectedPermission { action: string; resource: string; /** `${action}-${resource}` — matches the existing CRUD slug convention. */ slug: string; name: string; description?: string; /** Declaring plugin name ("app" for app-declared). Persisted on the row. */ owner: string; /** * Which KIND of declaration this came from, host or plugin. * * Carried beside `owner` rather than read out of it, because `owner` cannot * answer the question: the host's sentinel is the literal string `"app"`, and * a plugin may legally be named `app`. Anything grouping these by plugin would * then file every host-declared permission under that plugin. Not persisted — * `owner` remains the stored attribution, so no row changes shape. */ source: "app" | "plugin"; /** * Heading within the owner's section. Defaulted here rather than left * undefined, so grouping never has to decide what an absent group means. */ group: string; /** True for a permission the admin should warn before granting. */ danger: boolean; } /** * Result from a seeding operation. */ interface SeedResult { /** Number of permissions newly created */ created: number; /** Number of permissions that already existed (skipped) */ skipped: number; /** Number of errors encountered */ errors: number; /** Total permissions processed */ total: number; /** IDs of newly created permissions (for super_admin assignment) */ newPermissionIds: string[]; } declare class PermissionSeedService extends BaseService { private _permissionService?; private _rolePermissionService?; constructor(adapter: DrizzleAdapter, logger: Logger); private get permissionService(); private get rolePermissionService(); /** * Seed all system resource permissions. * * Ensures all permissions from the SYSTEM_PERMISSIONS constant exist. * System permissions cover: users, roles, permissions, media, settings, * email-providers, email-templates. */ seedSystemPermissions(): Promise; /** * Seed CRUD permissions for a single collection. * * Creates 6 permissions: create, read, update, delete, publish, unpublish. * * Publishing is seeded for every collection, not only those with the * draft/published lifecycle enabled. A collection can gain `status: true` * later, and a permission that appears only once someone flips a flag is one * nobody has granted at the moment it starts being enforced. * * @param collectionSlug - The collection slug (e.g., "posts", "products") */ seedCollectionPermissions(collectionSlug: string): Promise; /** * Seed read/update permissions for a single (global document). * * Singles have no create/delete lifecycle — they are auto-created on first * access and cannot be deleted. They DO have a publish lifecycle: a Single * carries the same `status` column and is published today by an ordinary * update, so it needs the same publish permissions a collection does. * * @param singleSlug - The single slug (e.g., "site-settings", "header") */ seedSinglePermissions(singleSlug: string): Promise; /** * Seed permissions for ALL dynamic collections. * * Reads all collection slugs from the `dynamic_collections` table * (including plugin-registered collections) and seeds the six CRUD and * publish-lifecycle permissions for each. */ seedAllCollectionPermissions(): Promise; /** * Seed permissions for ALL registered singles. * * Reads all single slugs from the `dynamic_singles` table and seeds * read, update, publish and unpublish permissions for each. */ seedAllSinglePermissions(): Promise; /** * Seed plugin-declared custom permissions (D36). Idempotent — the existing * `(action, resource)` unique index + `ensurePermission` make re-seeding a * no-op. New IDs are returned for super-admin assignment by the caller. */ /** * Give a built-in permission back to the presets, on a database that already got it wrong. * * Ownership is what `role-presets.ts` reads to decide a permission is a plugin's, so a row left * attributed goes on being withheld from Editor however the declaration is treated now. Matched * case-insensitively, the way `ensurePermission` matches. * * `orphanedAt` is cleared with it, and has to be: the orphan sweep skips a row with no owner, so * a permission marked while it was misattributed — declared, then absent for one boot, then * declared again — would never be unmarked by anything, and `listPermissions` filters marked * rows out before the presets are seeded. The permission would exist, and its collection would * exist, and Editor would still not be granted it. This is the same reconciliation * `ensurePermission` performs for a row it writes; a row withheld from it needs it too. */ private returnPermissionToPresets; seedCustomPermissions(perms: CollectedPermission[]): Promise; /** * Mark permissions whose declaring package has stopped declaring them, and * unmark any that are declared again. * * `ensurePermission` writes `owner` only for a permission that is declared, * so once a declaration goes the attribution freezes at whatever was last * true. That was cosmetic until presets began reading `owner` to decide * whether a permission is a plugin's; a stale attribution now silently * changes what a preset grants. * * Marked, not deleted, and grants are left alone. Absence from config is not * an uninstall: a plugin can be disabled and still declare its permissions, * a config can be edited by mistake, and there is no uninstall event to tell * the difference. Deleting on that evidence would revoke access as a side * effect of a config change. `cleanupOrphanedPermissions` retires them later, * on purpose. * * Only permissions with an `owner` are considered: a collection's CRUD seeds * have no declaring package, and their lifecycle follows the collection. * * @param declared - Every custom permission currently declared, from every * plugin, including disabled ones. */ markOrphanedPermissions(declared: CollectedPermission[]): Promise; /** * Assign newly created permissions to the super_admin role. * * Ensures the super_admin role retains full access when new permissions * are generated. Only assigns permissions that aren't already assigned. * * @param permissionIds - IDs of newly created permissions to assign */ assignNewPermissionsToSuperAdmin(permissionIds: string[]): Promise; /** * Delete all permissions for a specific collection or single. * * Removes all permissions where the resource matches the given slug. * First removes the permissions from all roles, then deletes the permissions. * This is typically called when a collection or single is deleted. * * @param resourceSlug - The collection or single slug (e.g., "posts", "site-settings") * @returns Result with count of deleted permissions */ deletePermissionsForResource(resourceSlug: string): Promise; /** * Remove permissions for dynamic resources that no longer exist. * * This is NOT auto-run — it must be called explicitly to prevent * accidental permission loss. Removes permissions whose resource is not a * system resource, not found in dynamic_collections, dynamic_singles or * dynamic_components, **and** which no package declared. * * Plugin-declared permissions (`owner` set) are never removed here. Their * resource is a name the plugin chose and is not expected to appear in any * of those tables, so the resource check cannot judge them. Retiring one * whose plugin has genuinely gone needs a signal this does not have — * absence from config is not an uninstall, and disabled plugins still * declare their permissions. * * First removes permissions from all roles, then deletes the permissions. */ /** * Rename permissions whose slug is exactly its own `resource-action`. * * A slug is what every authorization check resolves — the middleware, the * guards, `hasPermission`, and the scopes an API key is issued with — so a * row written the other way round is a permission nothing can find. It * denies rather than escalates, which is why it goes unnoticed: the grant is * listed as assigned and simply never applies. * * Renaming revokes nothing. Identity is `(action, resource)` and grants * reference the row by id, so this brings a label into line and leaves every * assignment intact. * * Only the exactly-reversed form. A slug that merely differs from the * convention was chosen by whoever declared it — `manage-api-keys` on action * `update` is in the seed set on purpose — and renaming those would break * the declarations that use them. * * A rename can still collide, because `slug` is unique and some other row * may already hold the canonical name. That is left in place rather than * resolved: guessing which of two permissions should own a name is not * something a boot-time repair should decide, and failing the boot over it * would be worse than the stale slug. */ private normalizeReversedSlugs; cleanupOrphanedPermissions(): Promise; /** * The `${action}:${resource}` pairs the built-in seeders own. * * Read from the same slug sources the seeding passes read — which include entities that exist * only in the database — and the same action lists they seed. Deriving it any other way is what * let the reservation and the seeder disagree about which entities exist. * * A database that cannot answer yields an empty set, leaving today's behaviour rather than * refusing every declared permission on a fresh or half-migrated install. */ private builtInOwnedPermissions; private getAllCollectionSlugs; private getAllSingleSlugs; /** * Every field-group slug the registry knows about. * * 🔴 Addressed by the resolved name rather than through * `getDialectTables().dynamicFieldGroups`, whose Drizzle object carries the * legacy table name. On a database whose storage migration has run, that * object names a table that is gone; the missing-table error is caught by * `cleanupOrphanedPermissions`, which then reports no removals while every * field-group permission is silently treated as orphaned. * * Issued as a statement rather than through the query builder for the same * reason the migration reads the registry that way: the builder resolves a * table through the schema registry, and the name to address here is the one * the catalog reports. */ private getAllComponentSlugs; private slugToLabel; private emptySeedResult; private mergeSeedResult; } /** * Content-localization config types. * * @module domains/i18n/config/types */ /** A locale as authored in config — a bare code or a full object. */ type LocaleInput = string | { code: string; label?: string; /** right-to-left content rendering for this locale's field inputs. */ rtl?: boolean; /** a single fallback code or an ordered chain. */ fallbackLocale?: string | string[]; }; /** The user-facing localization config block on `NextlyConfig`. */ interface LocalizationConfig { locales: LocaleInput[]; defaultLocale: string; /** fall back to another locale's value when a field is untranslated. Default `true`. */ fallback?: boolean; } /** A locale after normalization — all fields present, `fallbackLocale` an array. */ interface ResolvedLocale { code: string; label: string; rtl: boolean; fallbackLocale: string[]; } /** Normalized localization config stored on `SanitizedNextlyConfig`. */ interface SanitizedLocalizationConfig { locales: ResolvedLocale[]; defaultLocale: string; fallback: boolean; } /** * A key's stored value plus whether the key exists. * * `value` is nullable on a present key because the row's value column may be * SQL NULL or may hold the JSON literal `null`; neither is distinguishable * from the other once decoded, but both are distinguishable from absence. */ type MetaEntry = { present: true; value: T | null; } | { present: false; }; /** * MetaService — small KV API over the `nextly_meta` table. * * Used for runtime flags that don't belong in collection schemas * (e.g., `seed.completedAt`, `seed.skippedAt`). All values are JSON * round-tripped: callers pass / receive JS values; the service * handles serialisation. Pg/MySQL native JSON columns store the * serialised string verbatim (no double-decoding on read since the * service is the only writer). * * Cross-dialect: looks up the right Drizzle table via `this.dialect`. */ declare class MetaService extends BaseService { constructor(adapter: DrizzleAdapter, logger: Logger); private get table(); private get drizzle(); /** * Read a key together with whether a row for it exists at all. * * `get` collapses three different situations onto `null`: no row, a row whose * value column is SQL NULL, and a row holding the JSON literal `null`. * Callers for which "absent" and "present but carrying nothing readable" mean * different things must use this instead, because for them the difference * decides whether it is safe to proceed. */ getEntry(key: string): Promise>; get(key: string): Promise; set(key: string, value: unknown): Promise; /** * Write a key only if no row for it exists yet, leaving any existing row untouched. * * `set` reads the row and then inserts or updates, so two processes writing the same new key both * see nothing and both insert — one gets a primary-key violation, and if they disagree about the * value the survivor is whichever landed last. That is fine for a flag being stamped with the * same value from every caller, and not fine for a key whose value records a decision the losing * caller must abide by. * * Resolved by the database rather than by reading first: the conflict clause makes the check and * the write one statement. PostgreSQL and SQLite express it as `ON CONFLICT DO NOTHING`; MySQL * has no such clause and gets the equivalent no-op update of the key onto itself, so the row is * matched and left as it is. The builder is feature-detected because Drizzle exposes these under * different names per dialect. * * Says nothing about who won, deliberately. A caller that needs to know reads the row afterwards * and decides from its contents, which also covers losing to a caller that wrote the same value. */ insertIfAbsent(key: string, value: unknown): Promise; /** * Replace a key's value only if it still holds `expected`, reporting whether it did. * * The missing half of {@link insertIfAbsent}. That one settles a race to CREATE a key; this one * settles a race to MOVE one, which is a different problem and equally unserved by `set`: two * processes that both read the same value and both write leave whichever landed last, with * neither able to tell that it lost. * * Compares the serialised form rather than the decoded value, so the check is the same string * equality the database can perform in the `WHERE` clause — no read, no window between the * comparison and the write. * * False means the row has moved on: it was deleted, or someone else already claimed it. Callers * re-read and decide, because "someone else won" and "someone else won with the same intent" are * not the same outcome. */ compareAndSet(key: string, expected: unknown, next: unknown): Promise; delete(key: string): Promise; getAll(): Promise>; } /** * Retention pass scheduling, shared by every domain that prunes. * * Retention has no scheduler to hang off. Nextly is a library inside someone * else's Next.js app: there is no daemon, and an in-process timer would run on a * self-hosted server and silently never fire on serverless, where the instance * is frozen between requests. That failure mode is environment-dependent and * invisible, which is worse than not having a timer at all. * * So passes are gated on a stored timestamp instead of scheduled. Any caller may * offer to run one; the gate lets at most one through per interval per install, * whatever the process or request count. * * Claiming is atomic where the database allows it. A read-then-write gate would * let every instance of a multi-instance deployment win the same interval, so * each would run its own pass and the coordination the stored marker exists for * would buy nothing. `UPDATE ... WHERE` with an affected-row count would be the * natural primitive, but the adapter cannot report one portably — `update` * returns an empty array on dialects without RETURNING. `delete` does return a * reliable count on all three, so the claim is expressed as a conditional * delete of the marker followed by re-inserting it. * * That leaves a window of two statements rather than one, which is not perfect * mutual exclusion. It is a large improvement over a whole interval, and the * cost of a loss is only a second bounded, idempotent pass — the overlapping * deletes simply remove fewer rows. * * @module domains/retention/gate */ /** * The atomic claim primitive. Implemented against `nextly_meta` in * {@link MetaRetentionGate}; tests supply their own. */ interface RetentionGateStore { /** * Take the marker if it is absent or older than `dueBefore`, stamping it with * `now`. Returns true only for the caller that took it. */ claim(key: string, dueBefore: Date, now: Date): Promise; /** * Drop a marker this caller wrote, returning the turn. Optional: a store * that cannot release simply keeps the interval, which is the previous * behaviour rather than a new failure. */ release?(key: string): Promise; } /** * Runs retention passes on demand, at most one per interval per pass. * * The scheduling problem is the same for every domain that prunes, and it is * not the same as the pruning: there is no daemon to hang a timer off (see * `./gate`), so passes are offered opportunistically by write paths and gated. * Domains supply WHAT to prune; this decides WHEN, so a second domain needing * retention adds a pass rather than a second scheduler. * * Each pass is gated independently, on its own key and its own interval. A * single shared gate would let the first pass to run consume the interval for * all of them, and the busiest domain would starve every other one — which is * the failure that would look exactly like retention silently not working. * * @module domains/retention/runner */ /** One domain's retention work, plus how often it may run. */ interface RetentionPass { /** Distinguishes this pass's gate marker. Must be unique per pass. */ key: string; /** * Shortest time between two runs of THIS pass, asked EACH time a pass is * offered rather than captured when it was built. * * A runner built at boot outlives every hot reload, so a number copied in * here keeps its boot-time value: shortening the interval leaves pruning * delayed for hours, and lengthening it keeps pruning too often. Both the * in-process eligibility clock and the stored gate read it, so a stale value * is wrong twice. */ intervalMs: () => number; /** * @param maxBatches Caps this run when the caller is a write path, which * wants a bounded amount of work rather than a full backlog sweep. */ run(maxBatches?: number): Promise; } interface RetentionRunnerDeps { passes: RetentionPass[]; gate: RetentionGateStore; /** Injectable so tests can move time without sleeping. */ now?: () => Date; logger?: Logger; } declare class RetentionRunner { private readonly deps; /** * Epoch ms of this process's last offer per pass key. * * The TIME rather than a precomputed deadline, so the interval is applied at * comparison rather than baked in when the previous offer happened. A stored * deadline keeps whatever interval was current when it was written, so * shortening a six-hour window would still wait out the remaining six hours * once -- the exact delay the setting was changed to avoid. */ private readonly lastOfferedAt; constructor(deps: RetentionRunnerDeps); /** * `maybeRun` never throws and never rejects: callers hang it off a successful * content write, and housekeeping must not be able to turn that into an * error. One pass failing must not stop the others from being offered, so * each is attempted independently. */ maybeRun(maxBatches?: number): Promise; private runOne; } /** * Types for the cache-revalidation primitive. * * A write computes a {@link RevalidationIntent} — the set of cache tags (and, as * a fallback, paths) that a content change invalidates — from data available at * the write. The intent is framework-neutral: it is a list of plain strings, * computed in Node-safe core with no `next/*` import. A {@link CacheRevalidator} * implementation (registered by the framework adapter) later turns those strings * into `revalidateTag`/`revalidatePath` calls; core never touches `next/cache`. */ /** * A single path target for path-based invalidation. Paths are a fallback for the * cases where a route must flip (a slug's old URL 404-ing after unpublish), used * only when the route pattern is known; tags are the primary mechanism. */ interface RevalidatePathTarget { /** The route path or route pattern (e.g. `/blog/[slug]`). */ path: string; /** * Next.js `revalidatePath` type. `page` unless invalidating a whole layout. * Required when `path` is a dynamic route pattern (contains `[...]`), because * `revalidatePath` cannot match a pattern without it. */ type?: "page" | "layout"; } /** * The invalidation a single content change produces: the cache tags to bust and * any path targets to revalidate. Tags are deduplicated and never empty-string. */ interface RevalidationIntent { /** * Cache tags to invalidate, deduplicated. The derived tags are `nextly:`- * prefixed; tags supplied through a collection's `revalidate.tags` config are * merged in (trimmed, with blank entries dropped), so a caller may include * unprefixed tags of its own. */ tags: string[]; /** Optional path targets; present only when a known route must be flipped. */ paths?: RevalidatePathTarget[]; } /** * Per-collection / per-single revalidation configuration. A typed peer of * `status`/`versions`, replacing the previously untyped `custom.revalidateTags` * convention. */ interface RevalidateConfig { /** * Extra cache tags to bust on every write to this collection/single, merged * with the derived `nextly:*` tags. Use for a shared tag several reads carry * (for example a site-wide `navigation` tag). */ tags?: string[]; /** * Opt this collection/single OUT of automatic cache revalidation entirely. * @default false */ disable?: boolean; } /** * The framework-neutral sink for revalidation intents. The default * implementation is a no-op (non-Next runtimes, the CLI, tests); the Next * adapter implements it by mapping tags/paths to `revalidateTag`/`revalidatePath`. * Never throws: a revalidation failure must not turn a committed write into an * error. */ interface CacheRevalidator { /** Flush the given intents to the underlying cache. Best-effort, never throws. */ flush(intents: RevalidationIntent[]): void | Promise; } /** * Companion-aware read primitives (i18n M4). * * Localized collections store their translatable fields in a companion `_locales` table * (Option B). The read path resolves each localized field to the requested language with fallback. * Following Nextly's component-data precedent (spec §14 — "same cost profile as component data, * already batch-populated"), display resolution is a **batch populate**: one extra query fetches * the companion rows for the page of results, then values are merged onto each row in JS with the * blank-as-untranslated fallback rule (spec §8). Search / sort / where filtering, which must run * in SQL, use the EXISTS builder here instead (M4c). * * @module domains/i18n/companion-join */ /** One localized field: its API/row key (camelCase) + its physical companion column (snake_case). */ interface LocalizedFieldRef { /** Field name — the API/row key (e.g. `metaTitle`). */ name: string; /** Physical companion column — snake_case (e.g. `meta_title`). Used for SQL/lookup. */ column: string; } interface UserQueryResult { id: string; email: string; emailVerified: Date | null; name: string | null; image: string | null; passwordHash: string | null; isActive?: boolean; createdAt?: Date; updatedAt?: Date; } interface AccountQueryResult { id: number; userId: string; provider: string; providerAccountId: string; type: string; } interface DatabaseInstance { query: { users: { findMany: (options: { columns: Record; where?: unknown; }) => Promise; findFirst: (options: { where?: unknown; columns: Record; }) => Promise; }; accounts: { findMany: (options: { where?: unknown; columns: Record; }) => Promise; }; passwordResetTokens: { findFirst: (options: { where: unknown; columns: Record; }) => Promise<{ id: string; identifier: string; expires: Date; } | undefined>; }; emailVerificationTokens: { findFirst: (options: { where: unknown; columns: Record; }) => Promise<{ id: string; identifier: string; expires: Date; } | undefined>; }; }; update: (table: unknown) => { set: (data: unknown) => { where: (condition: unknown) => Promise; }; }; delete: (table: unknown) => { where: (condition: unknown) => Promise; }; insert: (table: unknown) => { values: (data: unknown) => Promise; }; select: (columns: Record) => { from: (table: unknown) => { where: (condition: unknown) => Promise; }; }; } interface FileManagerConfig { schemasDir: string; migrationsDir: string; } /** * Function type for fetching collection metadata from the registry service. * This allows the FileManager to load collection fields without circular dependencies. * * Why `status?: boolean`: the runtime schema generator only * adds the `status` column when `{ status: true }` is passed in. Without * the flag here, UI-created status-enabled collections went through the * FileManager fallback path and produced a Drizzle descriptor without a * status column — so `select()` left status out of GET responses and the * admin's published-edit branch could never fire. The fetcher now reads * the column from `dynamic_collections` / `dynamic_singles` and forwards * a coerced boolean to FileManager's runtime generation. */ type CollectionMetadataFetcher = (collectionName: string, executor?: unknown) => Promise<{ fields: FieldDefinition[]; tableName: string; status?: boolean; /** * Whether content-localization is enabled for this collection (i18n M4). When true, the * companion `_locales` table holds the translatable columns and * {@link CollectionFileManager.loadCompanionSchema} can build its queryable Drizzle table. */ localized?: boolean; } | null>; /** The companion `_locales` runtime schema for a localized collection. */ interface CompanionSchema { /** The queryable Drizzle table object for `_locales`. */ table: unknown; /** Physical companion table name (e.g. `dc_pages_locales`). */ companionTableName: string; /** * The collection's translatable fields (they live on the companion). Each carries both the * camelCase field name (row key) and the snake_case companion column, because the two differ * for camelCase fields (`metaTitle` → `meta_title`). */ localizedFields: LocalizedFieldRef[]; /** Whether the companion has a per-locale `_status` column (collection has Draft/Published). */ hasStatus: boolean; } declare class CollectionFileManager { private migrationsDir; private db; private schemaRegistry; private adapter?; private metadataFetcher?; constructor(db: DatabaseInstance, config: FileManagerConfig); /** * Set the adapter for runtime schema generation. * Called during service initialization. */ setAdapter(adapter: DrizzleAdapter): void; /** * Set the metadata fetcher for loading collection fields from the database. * This is used for runtime schema generation for UI collections. */ setMetadataFetcher(fetcher: CollectionMetadataFetcher): void; registerSchema(collectionName: string, schema: unknown): void; registerSchemas(schemas: Record): void; refreshSchema(tableName: string, freshTable: unknown): void; /** * Drop the slug-keyed cache entry so the lazy fetcher rebuilds the Drizzle * table from current `dynamic_collections` state. See `refreshSchema` for * the tableName-keyed variant used when the new table is already built. */ invalidateSchemaForSlug(collectionName: string): void; saveMigration(migrationSQL: string, migrationFileName: string): Promise; saveDropMigration(migrationSQL: string, migrationFileName: string): Promise; runMigration(migrationSQL: string): Promise; loadDynamicSchema(collectionName: string, executor?: unknown): Promise; /** * Load (and cache) the companion `
_locales` runtime Drizzle schema for a localized * collection (i18n M4). Returns `null` when the collection is not localized / has no localized * fields — callers use that to take the unchanged non-localized read path. * * Built on-demand from the collection's field metadata (mirrors {@link loadDynamicSchema}'s * fallback path) so the read path can JOIN the companion without a second table registry. * The result is cached under the companion's SQL name so repeated reads reuse one table object. */ loadCompanionSchema(collectionName: string, executor?: unknown): Promise; } /** * Access Control Service * * Evaluates access rules for collection operations (create, read, update, delete). * Supports predefined rule types for UI-created collections and custom functions * for code-first collections. * * This service is stateless and does not require database access. It evaluates * access based on the provided rules, context, and document data. * * @module services/access/access-control-service * @since 1.0.0 * * @example * ```typescript * import { AccessControlService } from '@nextly/services/access'; * import type { CollectionAccessRules } from '@nextly/services/access'; * * const accessService = new AccessControlService(); * * // Define access rules for a blog collection * const blogRules: CollectionAccessRules = { * create: { type: 'authenticated' }, * read: { type: 'public' }, * update: { type: 'owner-only' }, * delete: { type: 'role-based', allowedRoles: ['admin'] }, * }; * * // Evaluate access for a read operation * const result = await accessService.evaluateAccess( * blogRules, * 'read', * { user: { id: 'user-123', role: 'editor' } } * ); * * if (result.allowed) { * // Proceed with operation * if (result.query) { * // Apply query constraint for filtering * } * } else { * // Access denied: result.reason * } * ``` */ /** * Service for evaluating collection-level access control rules. * * Handles five types of access rules: * - `public` - Anyone can access (no authentication required) * - `authenticated` - Only logged-in users can access * - `role-based` - Only users with specific roles can access (OR logic) * - `owner-only` - Only the document owner can access * - `custom` - Code-defined function (code-first collections only) * * ## Key Features * * - **Query Constraints**: For `owner-only` read operations, returns a query * constraint to filter documents instead of denying access entirely * - **Custom Functions**: Supports dynamic import of custom access functions * for code-first collections * - **No DB Required**: Stateless service that evaluates rules without database access * * @example Basic usage * ```typescript * const accessService = new AccessControlService(); * * const rules: CollectionAccessRules = { * create: { type: 'authenticated' }, * read: { type: 'public' }, * update: { type: 'owner-only' }, * delete: { type: 'role-based', allowedRoles: ['admin'] }, * }; * * // Check if user can create * const canCreate = await accessService.evaluateAccess( * rules, * 'create', * { user: { id: 'user-123' } } * ); * // { allowed: true } * * // Check if anonymous user can create * const anonCreate = await accessService.evaluateAccess( * rules, * 'create', * {} // No user * ); * // { allowed: false, reason: 'Authentication required' } * ``` * * @example Owner-only with query constraint * ```typescript * const rules: CollectionAccessRules = { * read: { type: 'owner-only', ownerField: 'authorId' }, * }; * * const result = await accessService.evaluateAccess( * rules, * 'read', * { user: { id: 'user-123' } } * ); * // { * // allowed: true, * // query: { authorId: { equals: 'user-123' } } * // } * ``` */ declare class AccessControlService { /** * Evaluate access for a given operation. * * Returns an `AccessEvaluationResult` indicating whether access is allowed, * an optional query constraint for filtering, and an optional denial reason. * * ## Behavior by Rule Type * * | Type | Behavior | * |------|----------| * | `public` | Always allowed | * | `authenticated` | Allowed if `context.user` exists | * | `role-based` | Allowed if user has ANY of the allowed roles | * | `owner-only` | Read: returns query constraint; Others: checks document ownership | * | `custom` | Executes custom function via dynamic import | * * ## Default Behavior * * If no rule is defined for an operation, access is allowed (public by default). * This ensures backward compatibility with collections that don't have access rules. * * @param rules - Collection access rules (or undefined for public access) * @param operation - The CRUD operation being performed * @param context - Request context with user information * @param documentId - Optional document ID (for read/update/delete) * @param document - Optional document data (for update/delete ownership checks) * @returns Promise resolving to access evaluation result * * @example * ```typescript * const result = await accessService.evaluateAccess( * { read: { type: 'authenticated' } }, * 'read', * { user: { id: 'user-123', role: 'editor' } } * ); * * if (result.allowed) { * // Proceed with operation * } else { * throw new Error(result.reason ?? 'Access denied'); * } * ``` */ evaluateAccess(rules: CollectionAccessRules | undefined, operation: AccessOperation, context: RequestContext$1, documentId?: string, document?: Record, defaultOwnerField?: string): Promise; private evaluatePublicAccess; private evaluateAuthenticatedAccess; private evaluateRoleBasedAccess; private evaluateOwnerAccess; private evaluateCustomAccess; private loadCustomAccessFunction; } /** * Who is asking for a related row, and what has already been resolved for them. * * Populating a relationship is a read of another collection, so it needs the * same things any read needs: the caller, what they are trusted with, the * language and lifecycle they asked for, and somewhere to memoise per-request * lookups. Every layer that can reach a related row — a collection read, a * Single read, a field group, a write response — carries this same set. * * It was previously declared separately in each of those layers. Adding one * concern then meant editing every declaration and auditing every call site for * the one that was forgotten, which has happened five times: the caller's * authenticated scope, the read locale, two per-request caches, and the * Draft/Published intent. Declared once, the next concern is one edit. * * @module services/collections/related-row-read-context */ /** * A target collection's read policy, as one expansion needs it. * * Declared here rather than in the service that resolves it so the context and * everything it carries live together, and so a layer that only forwards the * cache does not have to import from the service that fills it. */ interface TargetReadPolicy { rules: CollectionAccessRules | undefined; /** * Whether the collection has Draft/Published, so a read of it can resolve the * status its rows are filtered by. Taken from the same record the rules come * from rather than looked up separately. */ hasStatus: boolean; } interface RelatedRowReadContext { /** * The caller a related row is judged and redacted for. Absent means * anonymous, which is the same answer their own read would get. */ user?: Record; /** Trusted read: stored rules and the lifecycle default are both bypassed. */ overrideAccess?: boolean; /** * Which collections `overrideAccess` may actually reach, when the caller can * name them. * * A trusted read that populates a relationship reads the TARGET trusted too, * and the target's collection was never named by the caller — it was reached * through a field. For a caller who has already decided who is asking, that * is correct and this stays absent: the Direct API's semantics are unchanged. * * A caller serving one fixed audience is in the opposite position. It can * state its trusted set up front, and anything outside that set must be read * as the audience would read it. Supplying this narrows the bypass to the * collections named, per TARGET, at every fetch the expansion performs. * * A predicate rather than a list because the decision is asked once per * target collection at four separate points, and a caller may derive * membership rather than enumerate it. */ trusted: ((collection: string) => boolean) | undefined; /** * The caller's authenticated scope. A scoped API key is judged on its OWN * stamped grant and never on its owner's roles, so a super-admin-owned key * must not inherit the bypass its owner's session would get. */ authenticatedScope?: AuthenticatedScope; /** * The language the surrounding read resolved to, used when a target * collection's read rule filters on a localized field. * * Already resolved by the caller — the requested locale, or the default when * none was asked for — because resolving it needs the localization config, * which the layers below do not have. */ locale?: string; /** * The caller's Draft/Published intent, when they asked to see everything. * * Deliberately narrow. `"all"` is a statement about the caller's trust and * propagates; a concrete `draft` or `published` names the lifecycle of the * collection being read and says nothing about what it points at, so it does * not. Typed so a concrete value cannot be threaded here by mistake. */ status?: "all"; /** * Evaluate the target collection's FIELD read rules on the rows this pulls * in. Opt-in because "no caller supplied" and "anonymous caller" are * indistinguishable here and demand opposite outcomes. */ enforceFieldAccess?: boolean; /** * Evaluate the target collection's own read rules, independently of whether * fields are redacted. A Single's authorization view wants the second without * the first: its rule must read real values, and must still not be shown a row * the response will withhold. */ enforceCollectionAccess?: boolean; /** * WHEN the target's field read rules are applied to a related row. * * `"fetch"` -- the default -- applies them as the row is loaded. `"assembled"` * defers them to the post-assembly pass, which is the order a direct read * uses: the row's own field `afterRead` hooks run first, so a rule that masks * a value can be judged on the whole row rather than on one a denied sibling * has already been cut out of. * * Deferring is opt-in and the default is the safe one, because only a caller * that actually runs the post-assembly pass can promise the rules run at all. * A path that expands without it -- a Single, a write response -- leaves this * unset and keeps its protection where it is. */ fieldAccessStage?: "fetch" | "assembled"; /** * Ids withheld because a target collection refused this caller, keyed by * collection and id. * * A caller checking its expansion for completeness cannot otherwise tell a * deliberate refusal from a load that failed, and would report the first as * evidence that went missing. */ withheldByAccess?: Set; /** * Read policies already resolved during this expansion, so a relationship * holding many references reads its target's metadata once rather than once * per value. Holds the PENDING lookup: references resolve concurrently, so * caching only the settled value helps none of them. */ targetPolicies?: Map>; /** * Companion schemas already looked up during this expansion, for the same * reason. Populated only when a rule actually needs a companion filter. */ targetCompanions?: Map>; } /** * Field-level function registry: the bridge that makes code-first * field `validate` / `access` / `hooks` actually execute. * * The DB-backed collection registry serializes field definitions, which * drops functions — so the write/read services can never find them on the * field defs they load. This registry captures the function-bearing field * configs from the LIVE `defineConfig` object during service registration * and hands them back to the services by collection slug. * * globalThis-backed so dev-mode HMR re-execution reuses one store (the * same pattern as init/schema-snapshot-cache); re-registration replaces a * slug's entry wholesale, so a config reload never leaves stale functions. * * Semantics implemented here (matching the types' documented contracts): * - `access.create/update`: a `false` result strips the field from the * write silently (the caller keeps working with the fields they may * touch); `overrideAccess` bypasses. * - `access.read`: a `false` result strips the field from serialized * responses. * - `hooks.beforeValidate` / `hooks.beforeChange`: transform the incoming * field value (return value replaces it; `undefined` keeps it). * - `hooks.afterChange` / `hooks.afterRead`: observe/transform the stored * value on the way out. * * @module shared/lib/field-level-registry */ /** * The values each read-access pass removed from a row, keyed by the row OBJECT, * so a later pass over the same row can restore them as EVIDENCE before it * re-judges the row. * * The nested-read pipeline applies access to a related row BEFORE its parent's * hooks (so a hook cannot read a denied child field to copy it), then AGAIN * after all hooks (a hook may have written a denied field back, mutated a row in * place, or added, replaced, or reordered rows). The second pass MUST re-run the * rules against the post-hook content — a cached verdict cannot be trusted once a * hook may have changed what the rule reads. Re-running alone would flip a * verdict, though: the first pass already removed the denied values a rule reads * as evidence, so a field kept only while such a value was present would be * wrongly dropped. Restoring each row's removed values first (unless a hook has * since set them) gives the rules the same evidence the first pass — and a direct * read — judged against, while the current values of everything a hook touched * are seen and re-judged. * * A restored value is EVIDENCE only, never response data: it is one the caller * was denied and the post-hook row did not itself supply, so it is removed again * once every snapshot that needed it has been taken. Otherwise a hook that flips * a field's CONDITION from deny to allow (say, a sibling `tier`) without * reintroducing the value would resurrect the value the first pass removed. * * The restore runs over the WHOLE subtree before any level's snapshot is taken, * because the evidence a rule reads is not always a sibling: an outer field's * rule can depend on a value inside a nested group or repeater the first pass * already redacted. Snapshotting a level before descending to restore its nested * rows would judge that outer rule against a stripped subtree and drop it, unlike * a direct read which judges once with the subtree intact. * * Keyed by the row object, never by an `id`: two rows may carry the same `id`, * and a replacement row is a genuinely new object that must be judged on its own * content. Row objects survive between the passes because the nested walk decodes * container values to arrays before hooks run. */ type ReadAccessRedactions = WeakMap, Record>; /** * Dialect-agnostic types for the `nextly_versions` table. * * `nextly_versions` is the single global content-version store: one JSONB * snapshot per captured document state, across all collections/singles/pages. * The per-dialect Drizzle tables (postgres/mysql/sqlite.ts) share these types. * * @module schemas/versions/types */ /** * Lifecycle state stamped on a version row. A restore is identified by a * non-null `sourceVersionNo`, not a distinct status, so the active set stays * small; `scheduled` is reserved for the future timed-publish executor. */ type VersionStatus = "draft" | "published" | "unpublished" | "scheduled"; /** The kind of document a version belongs to (Nextly's `{ kind, slug }` scope). */ type VersionScopeKind = "collection" | "single" | "page"; /** * Per-collection / per-single versioning options (the code-first + Schema * Builder config surface). Three orthogonal concerns are nested so invalid * combinations are unrepresentable: history is always on when versioning is * enabled; drafts add a draft/publish lifecycle; autosave coalesces the * in-progress draft. See the design spec section 3. * * NOTE (current stage): history/capture and the `drafts` draft/publish split * are enforced. On a `status: true` collection with drafts resolved on, a * status-less update to a published, non-localized document is stored as a * coalesced working draft instead of overwriting the live row, and publishing * promotes it. `autosave` coalescing and `maxPerDoc` retention pruning are * parsed and persisted but not yet acted upon. */ interface VersionsConfig { /** * Add a draft / published lifecycle. `false` = history-only (every write is * a restorable version, no draft state). An object configures autosave and * the reserved timed-publish flag. */ drafts?: boolean | { /** Coalesced autosave of the in-progress draft. Default 1000ms when on. */ autosave?: boolean | { intervalMs?: number; }; /** Reserve the `scheduled` status for future timed publish. */ schedulePublish?: boolean; }; /** Durable (non-autosave) versions kept per document. `false` = unlimited. Default 50. */ maxPerDoc?: number | false; } /** * The canonical, fully-defaulted shape every versioning consumer reads (the * mutation service, admin, plugin surface). `resolveVersionsConfig` produces * it; `null` means the entity is unversioned. */ interface ResolvedVersionsConfig { /** Always true on a resolved config (null represents "disabled"). */ enabled: true; drafts: { /** Draft/publish lifecycle on. `false` = history-only. */ enabled: boolean; autosave: { enabled: boolean; intervalMs: number; }; schedulePublish: boolean; }; /** Durable versions retained per document; `false` = unlimited. */ maxPerDoc: number | false; } interface CollectionMetadata { id: string; slug: string; tableName: string; description?: string; labels: { singular: string; plural: string; }; fields: FieldDefinition[]; timestamps?: boolean; /** * Whether the collection has the Draft/Published status feature enabled. * Backed by the `dynamic_collections.status` boolean column. When true, * the data table carries a `status` system column and the admin's Save * Draft / Publish split lights up. */ status?: boolean; /** * i18n: whether the collection is localized. Backed by the * `dynamic_collections.localized` boolean column. When true, translatable fields * live in the companion `
_locales` table and the admin edits per-language. */ localized?: boolean; /** * Resolved content-versioning config, or null when unversioned. Backed by * the `dynamic_collections.versions` JSON column. */ versions?: ResolvedVersionsConfig | null; /** * Cache-revalidation config, or null when the collection sets none. Backed by * the `dynamic_collections.revalidate` JSON column; the write path reads it to * honor `disable` and merge extra `tags`. */ revalidate?: RevalidateConfig | null; /** * Webhook recording policy, or null when the collection records (the * default). Backed by the `dynamic_collections.webhooks` JSON column; boot * reads it back so a Builder-authored opt-out survives a restart. */ webhooks?: StoredWebhookRecording | null; admin?: { group?: string; icon?: string; hidden?: boolean; useAsTitle?: string; order?: number; sidebarGroup?: string; isPlugin?: boolean; disableCreate?: boolean; pagination?: { defaultLimit?: number; limits?: number[]; }; }; source?: "code" | "ui" | "built-in"; locked?: boolean; configPath?: string; schemaHash: string; schemaVersion?: number; migrationStatus?: MigrationStatus; lastMigrationId?: string; accessRules?: { create?: { type: string; allowedRoles?: string[]; }; read?: { type: string; allowedRoles?: string[]; }; update?: { type: string; allowedRoles?: string[]; }; delete?: { type: string; allowedRoles?: string[]; }; publish?: { type: string; allowedRoles?: string[]; }; unpublish?: { type: string; allowedRoles?: string[]; }; }; hooks?: Record[]; createdBy?: string; } interface ListCollectionsOptions$2 { page?: number; limit?: number; search?: string; sortBy?: "name" | "slug" | "createdAt" | "updatedAt"; sortOrder?: "asc" | "desc"; includeSchema?: boolean; source?: "code" | "ui" | "built-in"; } interface ListCollectionsResponse { collections: TIncludeSchema extends true ? CollectionMetadata[] : Omit[]; total: number; page: number; limit: number; totalPages: number; } declare class DynamicCollectionRegistryService extends BaseService { private dynamicCollections; private dynamicSingles; constructor(adapter: DrizzleAdapter, logger: Logger); private ensureGlobalSlugUniqueness; registerCollection(metadata: CollectionMetadata): Promise; updateCollectionMetadata(collectionSlug: string, updates: Partial): Promise; /** * List collections with pagination, search, and sorting. */ listCollections(options?: ListCollectionsOptions$2 & { includeSchema?: TIncludeSchema; }): Promise>; getCollection(slug: string, executor?: unknown): Promise; collectionExists(slug: string): Promise; unregisterCollection(slug: string): Promise; } declare class DynamicCollectionValidationService { /** * @throws Error if the name is invalid */ validateCollectionName(name: string): void; /** * @throws Error if any field name is invalid or duplicated */ validateFieldNames(fields: FieldDefinition[]): void; /** * @throws Error if the relationship configuration is invalid */ validateRelationshipField(field: FieldDefinition): void; /** * The previous check only blocked `(?{` and `(?>` — * neither of which is even valid JS regex syntax, so the function * accepted catastrophic patterns like `(a+)+b` that DoS the database * regex engine on subsequent writes. * * The new gate has three layers: * * 1. **Length cap** (≤200 chars). Real validation patterns are * short; long patterns are usually obfuscated. * 2. **JS parse**. If `new RegExp(pattern)` throws, it's malformed * regardless of the runtime that will execute it. * 3. **`safe-regex2`** static analysis. Detects nested-quantifier * and alternation explosion patterns (the standard ReDoS shapes). * * In this codebase the runtime engine is the database (Postgres `~` * or MySQL `REGEXP`), not Node — JS-side runtime matching of admin- * supplied patterns does not exist here. So we don't pull in the * native re2 binding; the static `safe-regex2` check + length cap is * the load-bearing defense for what actually ships to the DB. * * @throws Error if the regex is invalid, too long, or unsafe */ validateRegexPattern(fieldName: string, pattern: string): void; } /** * DynamicCollectionSchemaService * * Handles SQL generation for dynamic collections: * - SQL migration generation (CREATE TABLE, ALTER TABLE, DROP TABLE) * - Junction table generation for many-to-many relationships * - Type mapping between field types and SQL types * * Drizzle `.ts` schema-code generation was removed: nothing imports the * generated files (the runtime builds its Drizzle table from * dynamic_collections metadata via generateRuntimeSchema), so they were * orphan output. Singles and components never generated them. * * Supports multiple database dialects: postgresql, mysql, sqlite * * @example * ```typescript * const schemaService = new DynamicCollectionSchemaService(validationService, 'sqlite'); * const sql = schemaService.generateMigrationSQL('dc_posts', fields); * ``` */ type SupportedDialect$3 = "postgresql" | "mysql" | "sqlite"; declare class DynamicCollectionSchemaService { private validationService; private dialect; constructor(validationService?: DynamicCollectionValidationService, dialect?: SupportedDialect$3); /** * Quote identifier based on dialect */ private quoteIdentifier; /** * The column type for a declared `slug`, taken from the canonical * descriptor rather than this class's own type map. * * Every generated table gets a UNIQUE index on `slug`, and MySQL cannot * index a TEXT column without a prefix length. The canonical descriptor * already renders a text field as `varchar(255)` on MySQL, which is exactly * what the runtime Drizzle table and the schema diff use for this column; * this class's map renders it as `text`. The DDL therefore failed on the * CREATE INDEX and left the table uncreated, and any table that did exist * disagreed with the schema every later diff compared it against. * * Scoped to `slug` because that is the column this class indexes on * creation. The two mappings still disagree elsewhere — see the note on * `mapFieldTypeToSQL`. */ private canonicalSlugType; /** * Whether a field kept its name but moved between storage classes. * * A junction-backed field has no column on this row and a row-backed one has no junction table, * so changing between them is not a modification of anything: it is a removal from one storage * and an addition to the other. Read as a modification instead, it produced an ALTER COLUMN * against a name the table never had. */ private storageClassChanged; /** * Whether the field's column is unique. * * A one-to-one relationship is unique by its cardinality, not by anything the author ticks, so * the flag alone does not decide it. Asked in one place because the answer has to be the same * whether the column arrives with its table or is added to one that already exists: a table * that gained its one-to-one by an edit was not enforcing the cardinality it declared. */ private columnIsUnique; /** * The statement that makes a column unique, in the one spelling everything else expects. * * `uq_
_` is what the desired schema declares for a unique field and what the * add-column path already emits, so a column that arrives with its table and the same column * added by a later edit now produce the same object. Asked in one place because the previous * arrangement — inline at create, named on add — made a table's physical shape depend on WHEN * the column appeared. * * MySQL is the exception on `IF NOT EXISTS`: it rejects the clause on CREATE INDEX rather than * ignoring it. */ private uniqueIndexSql; /** * Whether this dialect can carry the uniqueness as a NAMED index on this column. * * Delegates to the shared rule rather than restating it. This class, the add-column path and the * desired schema all decide the same thing about the same column, and a copy here that agreed on * the day it was written would drift silently — both spellings look correct in isolation, and * the disagreement only shows up as a diff proposing an index the generator never writes. * * Bounding a MySQL text column would make it keyable, and is the right end state, but it belongs * in the shared column descriptor: bounding it HERE alone makes the created column disagree with * the type the desired schema derives, and the next reconciliation tries to convert it back — * which MySQL cannot do while a full-value unique index stands on it. */ private uniquenessCanBeAnIndex; /** * The name of the UNIQUE index the create path emits for this field, or null when it emits none. * * Asked by the emitter and by `plannedAttachments` alike, so a prediction of what a create * artefact installs cannot disagree with what it installs. When only the emitter knew, a create * that had not been deployed yet still added this index, while the edit that followed emitted a * bare `DROP COLUMN` — which SQLite refuses while any index still names the column. */ private plannedUniqueIndexName; /** * Whether the field's column is indexed. * * A relationship is indexed whether or not the author asked: it is joined on every read that * expands it, and PostgreSQL does not index a foreign key on its own. Everything else is * indexed only on request. Asked in one place for the same reason as `columnIsUnique` — a * column added to an existing table was reaching neither rule, so a relationship created with * its table was indexed and the identical field added by an edit was not. */ private columnIsIndexed; /** * What happens to this row when the row it points at is deleted. * * A required relationship cannot be nulled out: the column forbids it. MySQL says so when the * constraint is created — "Column cannot be NOT NULL: needed in a foreign key constraint SET * NULL" — and PostgreSQL accepts the pair and fails later, at the delete, which is worse. So a * required relationship restricts the delete instead, and an optional one nulls the reference. * That is the same rule Prisma applies, for the same reason: the action has to be one the * column can actually perform. */ private relationOnDelete; /** * Every name this generator may have given one column's index, current first. * * Bounding long names changed what they are called, and an index already in a database still * carries the name it was created under. Looking only for the current one leaves the old index * in place while the field records that it was removed — the table then keeps enforcing * something the schema no longer says. The dialects even disagree on the legacy name: SQLite * stored it whole, PostgreSQL truncated it to 63 characters, and MySQL could not create it at * all, so an over-long name never existed there to find. * * Which of these the table actually has is decided by the live index list, never guessed. */ /** * Every name this generator may have given one column's index, current first. * * Bounding long names changed what they are called, and an index already in a database still * answers to the name it was created under. Looking only for the current one leaves the old * index in place while the field records that it was removed. The dialects even disagree on * the legacy name: SQLite stored it whole, PostgreSQL truncated it to 63 characters, and * MySQL could not create an over-long one at all, so none exists there to find. * * Which of these the table actually has is decided by the live index list, never guessed. */ /** * The names a column's UNIQUE index may carry, for the paths that REMOVE the column. * * Deliberately NOT part of `indexNameCandidates`. That list is also consulted when a field merely * turns its `index` flag off while staying unique, and a `uq_` name in it makes that path drop the * uniqueness itself — losing a guarantee the field still declares. */ private uniqueIndexNameCandidates; private indexNameCandidates; /** * `CREATE INDEX` for one column, in the spelling the dialect accepts. * * MySQL cannot index a `BLOB`/`TEXT` column without a key length and rejects the statement * outright, so a text-backed column is indexed by prefix. 191 characters is the longest prefix * that fits the 767-byte index limit under utf8mb4 on every InnoDB row format, including the * compact ones where a longer prefix is refused. PostgreSQL and SQLite index the whole value. */ private createIndexSql; /** * `DROP INDEX` for one column's index, in the spelling the dialect accepts. * * Emitted before the column it names: SQLite refuses `DROP COLUMN` while any index still * references the column, reporting it as a missing column inside the index rather than as the * removal it refused. */ private dropIndexSql; /** * The literal that backfills existing rows when a required column is added, or null when the * field's type states none. * * A relationship is the case with no answer: every id the generator could write references a * row that does not exist, and `DEFAULT NULL` does not satisfy `NOT NULL` on any dialect. */ private requiredColumnBackfill; /** * Generate SQL migration for creating a new collection table * * @param tableName - The name of the table to create * @param fields - Field definitions for the table * @param options - Optional configuration (reserved for future use) */ generateMigrationSQL(tableName: string, fields: FieldDefinition[], _options?: { isSingle?: boolean; /** * When true, inject a system `status` column ('draft' | 'published', * default 'draft', NOT NULL) so collections / singles that opt into * the Draft/Published lifecycle can persist the per-entry status. * * Without this, the Builder UI would persist `status: true` on * `dynamic_collections.status` but the data table (`dc_` or * `single_`) wouldn't have a column to write to, and the * first INSERT would fail with "table dc_X has no column named * status". Mirrors the runtime schema generator's `status` option * so the Drizzle table descriptor and the physical DDL stay in * lockstep. */ hasStatus?: boolean; /** * i18n: when true, translatable fields are omitted from this (main) table's * CREATE — they live in the companion `
_locales` table. Mirrors the * runtime schema generator so the physical DDL and the Drizzle descriptor * stay in lockstep for a UI-created localized collection. */ localized?: boolean; }): string; /** * Generate ALTER TABLE migration for updating a collection * * Note: SQLite has very limited ALTER TABLE support: * - ADD COLUMN is supported * - DROP COLUMN is supported (SQLite 3.35.0+) * - ALTER COLUMN (change type, nullability) is NOT supported * * For complex schema changes in SQLite, a table rebuild is required, * but for dynamic collections we keep it simple and only support * adding/removing columns. */ generateAlterTableMigration(tableName: string, oldFields: FieldDefinition[], newFields: FieldDefinition[], options?: { /** * Previous Draft/Published flag — pass `existing.status === true` so * the diff knows whether the table currently has a `status` column. */ wasStatus?: boolean; /** * New Draft/Published flag — pass the toggle value the user is * saving so the diff can ADD or DROP the `status` column when the * lifecycle is enabled or disabled. Pairs with `wasStatus`. */ hasStatus?: boolean; /** * Whether the table already holds rows, read from the live table by `tableHasRows`. * * Only a required column whose type states no backfill consults it, and only to decide * between emitting the column and refusing the edit. Undefined means the caller did not * look, which is read as "may have rows": guessing empty produces a statement that * PostgreSQL and MySQL reject and that SQLite accepts before rejecting every insert. */ tableHasRows?: boolean; /** * Foreign-key constraint names by column, read from the live table by * `readForeignKeyColumns`. * * Which columns carry one is not derivable from the fields: on SQLite the ALTER path * cannot attach a foreign key, so a relationship added by an edit has none while the same * field created with its table does. Undefined is read as "none known", which leaves the * drop exactly as it behaved before anything was measured. */ foreignKeysByColumn?: ReadonlyMap; /** * The index names the table carries, read from the live table by `readIndexNames`. * * Consulted before dropping one. Which columns are indexed is not derivable from the * fields: an index is created by whichever path added the column, and those paths have * not always agreed, so an identical field can be indexed on one table and not on * another. MySQL has no `DROP INDEX IF EXISTS`, so dropping an absent index aborts the * migration before the statements after it. Undefined means the caller did not look, and * the drop is then emitted only where the dialect can guard it itself. */ indexNames?: ReadonlySet; }): string; /** * Check if a field definition has been modified */ /** * Whether an edit changes the physical column, and therefore needs an ALTER. * * The column is compared through the descriptor rather than by listing the properties that * happen to affect it. A list is a claim about which properties matter, and it goes stale the * moment a new one is added: `dbType`, `precision`, `scale` and `options.format` all decide a * number's storage and none of them were listed, so changing a field to an exact decimal or * widening its precision produced no ALTER at all — the registry described a decimal while the * column stayed an integer, and every fractional write was still truncated. * * Asking the descriptor makes that class of omission impossible: whatever decides a column today * or later is, by construction, what this compares. * * `unique` and `index` are compared separately because they are not properties of the column's * shape. Two columns can be identical and differ in whether an index covers them. */ isFieldModified(oldField: FieldDefinition, newField: FieldDefinition): boolean; /** * Phase D (Option 2) — structural rename detection. * * Pairs a removed field with an added field if and only if: * 1. There is exactly ONE removed field (in oldFields, not in newFields) * 2. AND exactly ONE added field (in newFields, not in oldFields) * 3. AND their types are compatible (same `type`, and for relations * same target + relationType) * * This is the SAFE heuristic: zero ambiguity. If the user renames * multiple fields in a single save, the heuristic bails out and the * caller falls back to ADD+DROP. A console.warn surfaces the data- * loss risk so the user knows to rename one field at a time, OR an * admin-UI confirmation prompt can be added later (tracked as a * Phase D follow-up). * * Why not the more aggressive multi-pair scoring described in the * design doc: ambiguous pairings can silently rename to the wrong * column. The cost of that bug exceeds the cost of asking the user * to make smaller saves. We can soften this with an admin-UI * confirmation later if friction is real. */ detectFieldRename(oldFields: FieldDefinition[], newFields: FieldDefinition[]): { from: FieldDefinition; to: FieldDefinition; } | null; /** * Are two field definitions compatible enough that renaming one to * the other preserves data semantics? * * Strict by design: same type, and for relations same target + * relationType. Length differences are allowed for text/varchar * since a column rename doesn't touch the size constraint. Required/ * unique/index differences are allowed (those are independent * attribute changes the user can adjust on either side of a rename). */ private areFieldTypesCompatible; /** * Generate DROP TABLE migration SQL */ /** * The indexes and foreign keys a table WILL carry once its creation migration has run. * * A collection saved but not yet deployed has a registry record and no table. Reading the * absent table reports no attachments, and an edit made in that window then emits a bare * `DROP COLUMN` — which is correct against nothing and wrong against what the deployment * actually produces, because the create artefact runs first and installs the index and the * constraint the drop then trips over. * * Answered by the class that emits the CREATE, so what is predicted here and what is written * there cannot describe different tables. */ plannedAttachments(tableName: string, fields: FieldDefinition[]): { indexNames: Set; foreignKeysByColumn: Map; }; generateDropTableMigration(collectionName: string, tableName: string): { migrationSQL: string; migrationFileName: string; }; /** * Generate junction table SQL for many-to-many relationships */ generateJunctionTable(sourceTableName: string, field: FieldDefinition): string; /** * Generate junction table name following naming convention */ generateJunctionTableName(sourceTable: string, targetTable: string, fieldName: string): string; /** * Map field type to SQL column type (dialect-aware) * * This is a SECOND field-to-column mapping. The canonical one is * `getColumnDescriptor` in `domains/schema/services/field-column-descriptor`, * which the runtime Drizzle table and the schema diff both read, and the two * do not agree: a plain `text` field renders here as `text` on MySQL and as * `varchar(255)` there. A table created from this map is therefore compared * against a schema that describes it differently. * * Only the `slug` column is routed to the canonical descriptor so far — see * `canonicalSlugType` — because that is the one this class indexes on * creation, where the disagreement stops being cosmetic and refuses the DDL * outright. Converging the rest belongs with the column-descriptor * consolidation rather than with a per-column patch. */ /** * The column a number field reaches, for the dialect this service builds for. * * Two independent things can ask for fractions and they mean different storage. `dbType: * "decimal"` asks for exact fixed point, which is what money needs and what nothing else should * use; `options.format === "float"` is the UI's way of asking for an ordinary fractional number. * Silence means whole numbers. * * 🔴 "Exact" holds on PostgreSQL and MySQL, which have a real fixed-point type. SQLite has only * NUMERIC affinity: it stores what it can as an exact value and falls back to binary floating * point, and this package reads number columns back as JavaScript numbers either way. The column * is therefore the best storage SQLite offers rather than a guarantee, which is the same caveat * `NumberFieldConfig` already carries. * * Read here rather than inline in each dialect map because the same three-way answer is needed * three times, and a map that answered it per dialect is how one of the three came to be missing * from all of them. */ private numberColumnType; /** * Refuse decimal dimensions that cannot safely become part of a type. * * `precision` and `scale` are interpolated into DDL, and on this path they arrive from a request * payload that is only name- and plugin-validated. A value that is not an integer therefore * reaches the template verbatim, which at best renders a migration no engine accepts and at worst * carries whatever the string contains into a statement. * * The same rule the code-first config already enforces, reused rather than restated: the ranges * and the scale-not-greater-than-precision check are one definition, so the Schema Builder cannot * accept a shape `defineCollection` rejects. */ private assertDecimalDimensions; mapFieldTypeToSQL(declaredType: string, length?: number, options?: FieldDefinition["options"], validation?: FieldDefinition["validation"], /** * What a number field says about how it wants to be stored. * * Passed as its own argument because this map is reached from six call * sites and four of them used to hand over only a type and a length, which * is why an exact-decimal field silently became a whole-number column: the * facts that decide it never arrived. Optional so a caller that genuinely * has no field (a storage token resolved from a plugin type) is unchanged. */ numberStorage?: Pick): string; /** * Get a sensible default value for a field type. * Used when adding NOT NULL columns to existing tables. */ private getDefaultValueForType; /** * Format a default value for SQL (dialect-aware) */ formatDefaultValue(value: unknown, declaredType: string): string; /** * Convert snake_case to camelCase */ toCamelCase(str: string): string; /** * Map onDelete action to SQL syntax */ mapOnDeleteAction(action: string): string; /** * Map onUpdate action to SQL syntax */ mapOnUpdateAction(action: string): string; } /** * DynamicCollectionService is a facade over the validation, schema, and * registry services for dynamic collections. */ interface CollectionArtifacts { migrationSQL: string; migrationFileName: string; tableName: string; metadata: { id: string; slug: string; tableName: string; description?: string; labels: { singular: string; plural: string; }; fields: FieldDefinition[]; timestamps?: boolean; admin?: { group?: string; icon?: string; hidden?: boolean; useAsTitle?: string; }; source: "code" | "ui" | "built-in"; locked?: boolean; /** Draft/Published enabled. */ status?: boolean; /** i18n: collection is localized (translatable fields + companion table). */ localized?: boolean; schemaHash: string; schemaVersion?: number; migrationStatus?: MigrationStatus; createdBy?: string; }; } interface CreateCollectionInput$1 { name: string; label?: string; labels?: { singular: string; plural: string; }; description?: string; icon?: string; group?: string; useAsTitle?: string; hidden?: boolean; order?: number; sidebarGroup?: string; /** Whether the collection has the Draft/Published status feature enabled. */ status?: boolean; /** * i18n: whether the collection is localized. When true, translatable fields are * omitted from the main table and a companion `
_locales` table is created. */ localized?: boolean; /** Whether every save is recorded as a restorable version. */ versions?: boolean; /** Durable versions kept per document. `false` = unlimited, a number = keep * that many, undefined = the default (50). Ignored when `versions` is off. */ versionsMaxPerDoc?: number | false; /** Whether writes bust cache tags. Default on; false opts out entirely. */ revalidate?: boolean; /** * Whether writes are recorded to the webhook outbox. Default on; false keeps * this collection's content out of the outbox and every delivery. */ webhooks?: boolean; fields: FieldDefinition[]; hooks?: Record[]; createdBy?: string; } interface UpdateCollectionInput$1 { label?: string; labels?: { singular: string; plural: string; }; description?: string; icon?: string; group?: string; useAsTitle?: string; hidden?: boolean; order?: number; sidebarGroup?: string; /** Toggle Draft/Published. Honoured when defined; undefined leaves it unchanged. */ status?: boolean; /** i18n: toggle Internationalization. Honoured when defined; undefined leaves it unchanged. */ localized?: boolean; /** Toggle version history. Honoured when defined; undefined leaves it unchanged. */ versions?: boolean; /** Retention, honoured with the switch. `false` = unlimited, a number = keep * that many, undefined = the default (50). */ versionsMaxPerDoc?: number | false; /** Toggle cache revalidation. Honoured when defined; undefined leaves it unchanged. */ revalidate?: boolean; /** Toggle webhook recording. Honoured when defined; undefined leaves it unchanged. */ webhooks?: boolean; fields?: FieldDefinition[]; hooks?: Record[]; } declare class DynamicCollectionService extends BaseService { private validationService; private schemaService; private registryService; /** * i18n: the app's default locale — the language seeded onto/restored from the companion when * localization is enabled/disabled on an existing collection. Injected from the localization * config; defaults to "en" for setups without localization (where transitions never run). */ private readonly defaultLocale; /** * i18n: whether the constructing caller holds a localization config, when it * knows. `CollectionsHandler` takes one as a constructor argument and can be * built outside DI, so that instance must not be told localization is * unconfigured by a container it never used. Undefined defers to DI, which is * the registered-services path every dispatcher request takes. */ private readonly localizationConfigured?; constructor(adapter: DrizzleAdapter, logger: Logger, defaultLocale?: string, localizationConfigured?: boolean); /** * What the live table is, for the parts of an ALTER the field list cannot decide: whether a * required column can be added without a value for the rows already there, and which columns * are referenced by a foreign key that has to come off before they can be dropped. * * Both are read together and once per save, so the two cannot be observed at different * moments and a table is not queried twice for one edit. */ private readTableFacts; /** * Generate collection artifacts (SQL migration + TypeScript schema). */ generateCollection(data: CreateCollectionInput$1): Promise; /** * i18n: append the create-only companion `
_locales` CREATE statement to a * fresh localized collection's migration. Returns the original SQL unchanged when * the collection has no translatable fields (nothing to store per-locale). */ private appendCompanionCreateSQL; private generateSchemaHash; /** * Join SQL statements for a migration file the way the runner expects: each statement is * `;`-terminated and separated by `--> statement-breakpoint`, so the file splits into * single-statement chunks (drivers with multi-statements disabled, e.g. MySQL, otherwise * reject a multi-statement chunk). */ private toBreakpointSql; /** * i18n: build the data-preserving companion SQL for a localization enable/disable/field-change * on an existing collection (empty when there's nothing to do). Enabling seeds the companion * default locale from the existing main columns then drops them; disabling restores the default * onto main, archives the other languages into `nextly_i18n_archive`, then drops the companion; * a field change ADDs/DROPs localized columns. Returns `needsArchive` so the caller prepends the * archive table's `CREATE IF NOT EXISTS` DDL before a disable's archive INSERT. */ private buildCompanionTransitionSQL; /** * Generate update artifacts when collection schema is modified. */ generateCollectionUpdate(collectionName: string, updates: UpdateCollectionInput$1): Promise<{ migrationSQL: string | null; /** * What to run against THIS database, when it differs from the artefact. * * Present only where the local schema is in a shape migration history cannot produce: * unattended provisioning retains the columns it copied into a companion, so a later disable * meets a main table that already has them while the file — which must be replayable on a * database that only ever ran migrations — re-adds them. Null means the artefact is correct * here too, which is every case but that one. */ localMigrationSQL: string | null; migrationFileName: string | null; metadataUpdates: Record; }>; generateDropTableMigration(collectionName: string, tableName: string): { migrationSQL: string; migrationFileName: string; }; registerCollection(metadata: CollectionArtifacts["metadata"]): Promise; updateCollectionMetadata(collectionName: string, updates: Partial): Promise; listCollections(options?: ListCollectionsOptions$2 & { includeSchema?: TIncludeSchema; }): Promise>; getCollection(name: string, executor?: unknown): Promise; unregisterCollection(name: string): Promise; /** * Generate a unique ID in UUID v4 format. */ generateId(): string; getValidationService(): DynamicCollectionValidationService; getSchemaService(): DynamicCollectionSchemaService; getRegistryService(): DynamicCollectionRegistryService; } /** Carried across one read's nested-hook pass. */ interface NestedHookStateBase { /** * Rows already visited. Batch expansion hands the same object to every parent * that references it, so this is what keeps a transform from compounding * with the reference count; it also breaks a reference cycle. */ visited: Set>; /** One schema read per collection per read, rather than per row per depth. */ fields: Map; /** * Label field per target, keyed by collection AND the field's declared * override, since two relationships can point at one collection and name * different labels. Resolving it costs a metadata read, and the label is * rebuilt for every related row. */ labelFields: Map>; /** * The values field access removed from each related row, keyed by the row * object, shared across the whole read. The walk applies access to each row * before its parent's hooks (so a hook cannot read a denied child field to copy * it), recording what it removed here; finalize re-applies access after every * hook, restoring those values as evidence and re-judging the current content, * so anything a hook reintroduced, mutated, or added is caught. */ redactions: ReadAccessRedactions; /** * Every related row the pass reached, in visit order, with what is needed to * finish it. These entries drive the finalize step after every hook has run: it * re-applies access to each row (see `redactions`), then rebuilds labels last * from the values that survived. */ pending: Array<{ row: Record; collection: string; field: FieldDefinition; /** * How many hops from the document the row was reached at. Expansion honours * the depth remaining when it reaches a row, so the SAME row reached one hop * in and two hops in carries different population: the nearer occurrence has * its own relationships expanded where the deeper one has bare ids. */ depth: number; }>; /** * The AUTHORITATIVE version of each related row, keyed by collection and id: a * deep copy taken once the walk and the finalize step have fully sanitized it, * before any source-collection `afterRead` hook can reach it. * * The response's related rows are rebuilt from these rather than inspected for * tampering (see * {@link CollectionRelationshipService.reprojectRelatedRows}). A source hook is * free to clone, reshape, reorder, or write to a related row; whatever it did is * discarded when the row is re-derived, so no reshape has to be DETECTED to be * undone. A related row's presentation is its own collection's authority, so a * source hook cannot change it — including its allowed fields. * * Copies, never the live rows: a hook that mutates a related row in place would * otherwise corrupt the very version this restores from. */ sanitized: Map; }>; } /** The state the walk carries; named separately so the interface reads first. */ type NestedHookState = NestedHookStateBase; /** * The caller a related row is redacted for. * * Carried separately from {@link RelationshipExpansionOptions} because the fetch * helpers need only these two of its fields, and passing the whole options bag * down would let a depth value leak into a redaction decision. */ /** * The caller a related row is fetched, judged and redacted for. * * The shared shape, carried unchanged by every layer that can reach a related * row. Kept as a local name because this service refers to it constantly and * "access" reads better at those call sites than the full noun. */ type RelatedRowAccess = RelatedRowReadContext; /** * Options for relationship expansion. */ interface RelationshipExpansionOptions { /** * Maximum depth to expand relationships (0-5). * - 0: No expansion, return IDs only * - 1: Expand immediate relationships * - 2+: Expand nested relationships recursively * @default 2 */ depth?: number; /** * Current depth level (used internally for recursion). * @internal */ currentDepth?: number; /** * The caller a related row's field-level `access.read` rules are evaluated * against. * * Expansion spreads the whole related row into the parent entry, and the * parent entity's field registry never describes a related collection's * fields — so without the caller here, a field the target collection protects * is returned to anyone who populates the relationship. Absent means * anonymous, which denies any rule that inspects the user, matching how the * parent entry is redacted. */ user?: Record; /** * Trusted read: skip field-level read rules on related rows, matching * `applyFieldReadAccess`. Secret stripping (passwords, system columns) is NOT * skipped — a system caller has no reason to receive a password hash. */ overrideAccess?: boolean; /** * Narrows `overrideAccess` to the collections a caller names, judged per * expansion TARGET. See {@link RelatedRowAccess.trusted}. */ trusted?: (collection: string) => boolean; /** * Opt in to evaluating the target collection's field read rules. Set by the * read paths that forward a real caller; see {@link RelatedRowAccess}. */ enforceFieldAccess?: boolean; /** * Evaluate the target collection's own read rules even when field redaction * is off. See {@link RelatedRowAccess.enforceCollectionAccess}. */ enforceCollectionAccess?: boolean; /** * Defer the target's field read rules to the post-assembly pass. Only a * caller that runs {@link CollectionRelationshipService.applyNestedFieldHooks} * over the finished document may set this. See * {@link RelatedRowAccess.fieldAccessStage}. */ fieldAccessStage?: "fetch" | "assembled"; /** * Collects the ids withheld because a target collection refused the caller, * so a completeness check can tell a refusal from a failure. * * @internal */ withheldByAccess?: Set; /** * Target read policies already resolved during this expansion. * * Carried by a nested hop so the whole expansion resolves each target * collection's rules once. Not part of what a caller supplies. * * @internal */ targetPolicies?: Map>; /** * Companion schemas already looked up during this expansion, carried by a * nested hop for the same reason the policy map is. * * @internal */ targetCompanions?: Map>; /** * The caller's authenticated scope, when one applies. * * A scoped API key is judged on its OWN stamped grant rather than its * owner's roles, so a super-admin-owned key must not inherit the bypass its * owner's session would get. */ authenticatedScope?: AuthenticatedScope; /** * The caller's Draft/Published intent, when they asked to see everything. * * Deliberately narrow: `"all"` is the only value that propagates into * expansion. It is a statement about the caller's trust — the admin sends it * on every read for exactly that reason — whereas a concrete `draft` or * `published` names the lifecycle of the collection being read and says * nothing about what that collection points at. Absent means a related row is * filtered to the published default, which is what a direct read of it would * do. */ status?: "all"; /** * The language this read resolved to, forwarded so a target collection's read * rule can be applied when it filters on a localized field. * * Pass the resolved locale, not the raw request parameter: see * {@link RelatedRowAccess.locale}. */ locale?: string; } /** * CollectionRelationshipService handles all relationship expansion and junction table operations * for dynamic collections. * * Responsibilities: * - Expand relationships for single entries and batch operations * - Fetch related entries (oneToOne, manyToOne, oneToMany) * - Manage many-to-many relationships via junction tables * - Determine best label fields for display * * Uses the database adapter pattern for multi-database support (PostgreSQL, MySQL, SQLite). * Currently uses Drizzle queries with dynamic schemas and SQL tagged templates for complex * relationship queries that involve dynamic table names. * * @extends BaseService - Provides adapter access and Drizzle compatibility layer * * @example * ```typescript * const relationshipService = new CollectionRelationshipService( * adapter, logger, fileManager, collectionService * ); * const expanded = await relationshipService.expandRelationships(entry, 'posts', fields); * ``` */ type RelationshipDbExecutor = { all?(query: unknown): unknown[]; run?(query: unknown): unknown; execute?(query: unknown): Promise; }; declare class CollectionRelationshipService extends BaseService { private readonly fileManager; private readonly collectionService; /** * Decides whether a caller may read a TARGET collection at all. * * Built on first use rather than injected: this service is constructed before * the one that owns the access service, and resolving it lazily keeps that * ordering intact. Stateless, so a second instance costs nothing but the * construction. */ private accessService; private readonly accessControl; constructor(adapter: DrizzleAdapter, logger: Logger, fileManager: CollectionFileManager, collectionService: DynamicCollectionService); private resolveAccessService; /** * Drop the rows of a target collection this caller may not read. * * A related row belongs to another collection and carries that collection's * own read rules. Without this, a caller refused the collection outright * still obtains its rows by populating a relationship that points at them. * * Judged on the fetched ROW, not on the collection alone: a rule may be * keyed on the document id, and one evaluated with `undefined` there both * hides rows a rule permits and admits rows it forbids — `id !== blocked` * reads as true when the id never arrives. * * Only the STORED rules are evaluated, not the RBAC permission gate. The * route authorized this caller for the PARENT collection, and requiring a * permission naming a collection they never asked for by name would refuse * population for every caller whose grants do not list it. Whether expansion * should also require a read permission on the target is left open. * * Opt-in for the same reason field-level enforcement is: an entry point that * has not been given the caller yet would judge everyone anonymous and hide * rows from entitled callers. */ /** * Read the rows a relationship points at, as this caller may see them. * * The only place a related row is fetched. Six call sites used to repeat the * same five steps — resolve the schema, select by id, normalize timestamps, * apply the target collection's read rules, redact its protected fields — and * every capability a related row was missing had to be added to each of them * and then audited for the one that was forgotten. That audit has been run * three times: for the caller's scope, for the read locale, and for two * caches. Behind one seam each of those becomes a single change. * * System entities keep the lean path deliberately: they have no collection * record, so no stored rules and no hooks apply, and their secrets are * stripped by column name during redaction instead. * * Returns only the rows this caller may read. A refused row is simply absent — * one unreadable reference must not refuse the whole parent read — so callers * must not assume the result lines up with the ids they asked for. */ /** * The Draft/Published predicate a read of this target resolves to, or * undefined when none applies. * * What propagates from the surrounding read is not a status VALUE but whether * the published-only default was deliberately bypassed. A concrete * `?status=draft` names the lifecycle of the collection being listed, not of * everything it points at — a draft page should still show its published * author — so only "read everything" travels, and it travels because it is a * statement about the caller's trust rather than about the query. * * System entities have no lifecycle and no collection record to ask. */ private resolveTargetStatusValue; private readTargetRows; private filterRowsByCollectionAccess; /** * Note which rows a refusal removed, so a caller checking its expansion for * completeness reads them as absent on purpose rather than as evidence lost. * * Keyed by collection AND id, because an id is only unique within its own * collection: two targets can carry the same one, and a bare-id record would * let a refusal in the first excuse a genuine load failure in the second. */ private recordWithheld; /** * The target collection's read policy, resolved once per expansion. * * Reading it costs a collection-metadata query, and a `hasMany` relationship * fetches one row at a time — so resolving per row turns a relationship with * hundreds of values into hundreds of metadata reads against the same pool * the row fetches need. */ private resolveTargetReadPolicy; /** * Keep only the rows the target's own read predicate admits. * * Asked of the database rather than compared in memory: the predicate is a * full filter, and a second evaluator interpreting its operators is free to * disagree with the one the direct read compiles — a filter that binds less * than the rule states is how a read widens unnoticed. The same translation * the read path uses is applied here, over exactly the ids already fetched, * so this costs one query per target collection rather than one per row. * * A predicate that cannot be translated withholds every row. It is the same * refusal a direct read makes, expressed as absence, because an unreadable * relationship must not turn into an error on the document that points at it. */ private narrowByTargetPredicate; /** * The target collection's companion schema, looked up once per expansion. * * The PENDING lookup is cached rather than its result: references are * confirmed concurrently, so every one of them reaches this point before the * first has anything to store, and caching only the settled value leaves each * issuing its own metadata read. */ private resolveTargetCompanion; /** * The companion context a predicate on a localized field of the TARGET * collection needs, or null when there is none to build. * * A localized field has no column on the main table — it lives in the * collection's `_locales` companion, one row per language — so a predicate * naming one can only be applied as a subquery against that table. Without * this the field looked like a column the target does not have, and every row * behind such a rule was withheld from expansion while a list read of the * same collection returned them. * * Null when the target is not localized, or when no locale reached this * expansion: a companion filter has to name one language, and the read that * asked for every language at once (or never said) has no single answer. * Withholding is the outcome then, unchanged from before. * * Also null when the constraint names only columns the target already has. * Looking a companion up costs a collection-metadata read, and the ordinary * case — an owner or tenant predicate on a plain column — has nothing to * resolve there, so a localized application would pay for every target it * populates without a companion filter ever being built. */ private buildTargetLocalizedContext; /** * Whether one fetched row survives the target collection's read policy. * * Only verdicts are decided here. An owner-only rule answers a read with a * predicate, and it travels the same route every other predicate does — the * database applies it — so there is no comparison in this process for any * rule shape, and nothing that could read an operator differently from the * query the direct read compiles. */ private judgeRow; /** * Run a SELECT-style raw SQL tag and return its rows in a normalized * `{ rows: [...] }` shape regardless of dialect. * * Drizzle's raw-execute result shape differs across drivers: * - Postgres (node-postgres): `db.execute(sqlTag)` → `{ rows: [...] }` * - MySQL (mysql2): `db.execute(sqlTag)` → a `[rows, fieldPackets]` tuple * (or a flat rows array), NOT `{ rows }` * - SQLite (better-sqlite3): `db.all(sqlTag)` → `unknown[]` (no execute()) * * Without this helper, the junction-table code paths blow up at runtime on * SQLite (`this.db.execute is not a function`) and on MySQL (reading * `.rows` off the tuple yields undefined). The MySQL normalization mirrors * the defensive handling in schema/pipeline/classifier/count-helpers.ts. */ private selectRawSql; /** * Run an INSERT / UPDATE / DELETE / DDL raw SQL tag, dialect-aware. * Same rationale as `selectRawSql`. Returns void since callers don't * inspect the mutation result here. Accepts an optional handle for the same * reason as `selectRawSql` (defaults to the pool). */ private mutateRawSql; /** * Determine the best label field for a collection. * Tries to find a meaningful text field, not just ID. * * @param collectionName - Name of the collection or system entity * @param targetLabelField - Optional explicitly specified label field * @returns The best field name to use as a label */ getBestLabelField(collectionName: string, targetLabelField?: string): Promise; /** * Batch expand relationships for multiple entries (optimized for N+1 prevention). * Groups queries by relationship type to minimize database round trips. * * Supports depth parameter: * - depth=0: No expansion, return entries as-is * - depth=1+: Expand relationships (note: batch expansion only does 1 level for performance) * * For deeper nested expansion, use expandRelationships() on individual entries. * * @param entries - Array of entries to expand * @param collectionName - Name of the collection * @param fields - Field definitions for the collection * @param options - Expansion options including depth control * @returns Entries with expanded relationship data */ batchExpandRelationships(entries: Record[], collectionName: string, fields: FieldDefinition[], options?: RelationshipExpansionOptions): Promise[]>; /** * Batch fetch references that may span several collections, one query per * collection. A field declaring several targets holds values from more than * one of them, so a single fetch against the field's first target would miss * every value pointing anywhere else. * * Keys the result by collection and id together, since the caller resolves a * row from the reference it started with and an id alone does not identify * one across collections. * * @param refs - References to load, duplicates allowed * @param field - Field definition, for label resolution * @returns Map of {@link relationKey} to expanded entry data */ private batchFetchRefs; /** * Batch fetch related entries for oneToOne/manyToOne/oneToMany relations. * Returns a Map of ID -> { id, label }. * Uses Drizzle's inArray for clean, type-safe queries. * * @param targetCollection - Name of the target collection or system entity * @param relatedIds - Array of IDs to fetch * @param field - Field definition * @returns Map of ID to expanded entry data */ batchFetchRelatedEntries(targetCollection: string, relatedIds: string[], field: FieldDefinition, access?: RelatedRowAccess): Promise>>; /** * Batch fetch manyToMany relations for multiple source entries. * Returns a Map of sourceEntryId -> Array<{ id, label }>. * * @param sourceCollectionName - Name of the source collection * @param sourceEntryIds - Array of source entry IDs * @param field - Field definition * @returns Map of source ID to array of related entries */ batchFetchManyToManyRelations(sourceCollectionName: string, sourceEntryIds: string[], field: FieldDefinition, access?: RelatedRowAccess): Promise[]>>; /** * Expand relationship data for a single entry with depth control. * * Supports depth parameter: * - depth=0: No expansion, return IDs only * - depth=1: Expand immediate relationships * - depth=2+: Expand nested relationships recursively * * Also respects field-level `maxDepth` configuration to prevent * over-fetching on specific relationship fields. * * @param entry - Entry to expand * @param collectionName - Name of the collection * @param fields - Field definitions * @param options - Expansion options including depth control * @returns Entry with expanded relationship data */ expandRelationships(entry: Record, collectionName: string, fields: FieldDefinition[], options?: RelationshipExpansionOptions): Promise>; /** * Fetch media records by IDs. * Uses the media table to retrieve full media objects. * * @param ids - Array of media IDs * @returns Array of media records */ private fetchMediaByIds; /** * Get field definitions for a collection. * Helper method for recursive relationship expansion. * * @param collectionName - Name of the collection * @returns Field definitions or empty array if not found */ private getCollectionFields; /** * Strip values the caller may not see from related rows before they are * merged into a response. Relationship expansion spreads the entire related * row into the parent entry, so without this a related collection's password * fields (or the users entity's password hash) would be returned to any * caller that populates the relationship. Called once per fetch with the row * set, so the target schema is loaded at most once per relation, not per row. * * Two independent passes, because they answer different questions: * secrets are stripped for everyone, while field-level `access.read` is * evaluated against the caller. The parent entry's own redaction cannot cover * either one: it runs against the SOURCE collection's field registry, which * never describes a related collection's fields. */ private redactRelatedRows; /** * Apply each nested related row's OWN collection field `afterRead` hooks, * once the document is fully assembled. * * A field hook is the transforming half of a field's read protections -- the * half that masks a value on the way out. Running it at fetch time would be * wrong: related rows are read BEFORE the recursion that expands their own * relationships, so a hook masking on `data.organization.classification` * would see a raw id. A direct read expands first and runs field hooks last, * and expansion may be stricter than the target's own endpoint but never * looser. * * So this runs once, from the read path, over the finished document. * * `state` is shared across every entry in one read. Batch expansion hands the * SAME row object to every parent that references it, so a per-entry * traversal would run that row's hooks once per reference and compound any * transform that is not idempotent. It also carries the schema cache: without * it a hundred-row listing re-reads the same two collections' fields on every * row, one metadata query at a time. */ /** A state a caller can share across every entry in one read. */ createNestedHookState(): NestedHookState; applyNestedFieldHooks(entry: Record, collectionName: string, access: RelatedRowAccess, state?: NestedHookState): Promise; /** * Re-apply each related row's field access, then rebuild the labels. * * The walk already applied access to each row (before its parent's hooks, so a * parent hook cannot read a denied child field to copy it). This runs it again * because a hook can REINTRODUCE a denied field onto an already-redacted row * (assigning `data.child.secret` to mask or derive a value), mutate a row in * place, or add/replace/reorder rows -- and without a pass after the hooks that * would be returned. It re-judges the current content (a cached verdict cannot * be trusted once a hook may have changed what a rule reads), restoring from the * shared `redactions` what a prior pass removed from each row so a rule reading * a now-denied sibling as evidence still sees it -- keeping an unchanged verdict * stable, as a direct read's single pass would, while judging everything a hook * touched afresh. * * Called more than once per read, and safe to repeat: once after the related * rows' OWN field hooks (so the source collection's hooks are handed already * sanitized rows), and again after the SOURCE collection's code and stored * afterRead hooks. Those hooks receive the whole assembled document and can * write a denied field straight back onto a related row (`entry.author.secret`); * the root-level read-access pass evaluates only the source collection's schema * and never descends into a related row, so without a pass here after them the * reintroduced value is returned. It leaves `pending` in place for exactly that * repeat; the state is scoped to one read and discarded when it finishes. * * Labels come last of all, from the values that survived: a label copies a * field under another key, so one rebuilt earlier would outlive the removal of * its own source field. */ finalizeRelatedRows(state: NestedHookState, access: RelatedRowAccess): Promise; /** * Rebuild the ASSEMBLED response's related rows from the authoritative versions * the walk produced, discarding whatever a source-collection `afterRead` hook — * code, stored, or field-level — did to them. * * A source hook receives the whole assembled document, and the root read-access * pass evaluates only the SOURCE collection's schema and never descends into a * related row. So a hook can write a denied target field back onto one, clone or * reshape one, append or reorder nested rows inside one, or return a rebuilt * document whose related rows are objects the walk never held. Detecting each of * those and undoing it is unbounded work: every reshape variant has to be * modelled, and a rule that reads a value the reshape moved falls open on the * copy. * * Rebuilding instead makes the question moot. Each populated related row in the * response is replaced by a copy of the sanitized version recorded in * `state.sanitized`, matched on the collection and id the response itself names. * No tampering has to be found, because none of it survives. A related row the * walk never sanitized — one a hook fabricated — has no authoritative version, so * it is reduced to the bare reference rather than returned with unjudged fields. * * Runs after EVERY source hook phase, not only the last: a hook in one phase can * copy a value off a related row it just contaminated onto a SOURCE field, which * the next phase would then read. Restoring between phases means every phase is * handed clean related rows. It costs no query — the versions are already in hand. * * Runs before selection projects rows to slices, so the response holds whole, * consistent related rows at the point selection reads them. */ reprojectRelatedRows(entries: Record[], collectionName: string, access: RelatedRowAccess, state: NestedHookState): Promise; /** * Rebuild every relationship value held at one level of the response, and * descend through `group`/`repeater` containers to reach the ones nested inside * them. * * Container rows belong to the SOURCE collection, so they carry no authoritative * version of their own and are descended into rather than replaced. Only a * relationship value names another collection's row. */ private reprojectFields; /** Rebuild one relationship field's value, mapping a list one entry at a time so * each entry is matched against its OWN target collection. */ private reprojectRelationshipValue; /** * Rebuild one relationship entry from its authoritative version. * * A value that is still a bare reference is returned untouched: it carries no * fields, so there is nothing that could have been tampered with. A POPULATED row * is replaced by a copy of the sanitized version recorded for the collection and * id it names — and reduced to that bare reference when no such version exists, * because a populated row the walk never judged is one no rule has been applied * to. A row whose identity cannot be read at all (a clone a hook stripped the id * from) has no reference left to keep, so it is dropped. */ private reprojectRelationshipItem; /** * The collection's fields, read once per collection per read. * * A target whose schema will not load runs no hooks, and the read continues. * * That is weaker than a read protection deserves, and it is the only answer * the registry supports: it reports "this is not a registered collection" and * "the lookup failed" the same way, so a relationship pointing at a built-in * entity is indistinguishable from a real failure. Refusing on it would deny * ordinary reads. The failure is logged rather than swallowed. * * Refusing becomes correct once the registry can answer whether a slug IS a * collection separately from what its fields are. */ private fieldsForNestedWalk; /** * Decode a JSON-backed field held as a string into its objects, in place, so the * walk can descend into the relationships inside or behind it. * * A source `afterRead` hook can return a value as the storage string SQLite keeps * it as (the normal read decodes these before this walk; a hook that reshapes the * document can hand one back as a string). Two field kinds are JSON-backed: * `group`/`repeater` containers, and a POPULATED `hasMany` or polymorphic * relationship, which serializes to a JSON array (`[...]`) or object * (`{"relationTo":...}`). Left a string, {@link walkFieldValue} derives no rows * from it and a denied target field inside would reach the response. * * A relationship is decoded only when the string opens with `[` or `{`: a bare id * is left alone (parsing `"12"` would coerce it to a number), and a Postgres * array literal (`{id,...}`) is not JSON so {@link parseJsonIfString} returns it * unchanged. Writing the decoded value back matches the shape a normal read * returns. */ private decodeJsonBackedFieldInPlace; /** * Re-apply field access to the related rows already sanitized beneath `entry`, * after `entry`'s own field hooks ran (first walk only). * * The post-hook re-descent walks NEW children a hook added but skips ones already * visited. A field hook can instead reintroduce a denied field on an EXISTING * child in place; that child would stay contaminated while `entry` unwinds to its * PARENT, whose hooks could copy the value onto an allowed key the later * sanitization no longer looks at. Re-applying the existing children's access here * — without re-running their hooks — re-strips such reintroductions before `entry` * returns to its parent. The re-walk of the assembled response covers reshaped * rows separately. */ private reapplyDescendantAccess; /** Reach the related rows inside one field value — a relationship directly, or one * nested in a group/repeater — and re-apply access to each already-visited row, * recursing into its own relations. See {@link reapplyDescendantAccess}. */ private reapplyFieldValueAccess; /** * One level of {@link applyNestedFieldHooks}. * * Rows are claimed in {@link walkFieldValue} rather than here, so the claim * covers running a row's hooks as well as descending into it. The depth cap * mirrors the expansion's own maximum. */ private walkNestedRows; /** * Visit one field's value, whatever shape it takes. * * A relationship can sit directly on the collection or inside a `group` or * `repeater`, and `expandRelationships` populates it either way, so a walk * that only looked at top-level `relationTo` fields left everything inside a * container unmasked. */ private walkFieldValue; /** * Rebuild a related row's display label from the fields that survived. * * The label copies a field's value under another key, so one derived at fetch * outlives the removal of its own source field: a caller denied `internalName` * would still read it as `label`. Rebuilt here, after the hooks and the field * rules, it can only be made of values this caller may see. * * Falls back to the id, which is what the fetch-time derivation does when the * source field is absent, so a row stays identifiable rather than losing its * label entirely. * * Only rows that carry a label are touched. Expansion attaches one; a row * reached some other way has no label to keep honest. */ private refreshRelatedRowLabel; /** * Evaluate the TARGET collection's field-level `access.read` against the * caller, per related row. * * Kept separate from secret stripping so it cannot be skipped by that pass's * early exits: a target collection with no password field is the common case, * and returning early on it would leave every access rule unevaluated. * * Rules are the target collection's own, so this is the same decision the * related row would get if it were read directly. */ private applyRelatedRowReadAccess; /** * Resolve a collection's fields for redaction. Uses the same * `schemaDefinition.fields` (API shape) OR `collection.fields` (raw DB row) * fallback the rest of this service uses — `getCollection` returns the raw * row, whose fields live at the top level, so a `schemaDefinition`-only * lookup silently resolves to nothing and skips stripping. Returns null * when the schema cannot be resolved so the caller fails closed. */ private getRedactionFields; /** * Fetch a single related entry from a collection or system entity. * Supports both dynamic collections and system entities (like "users"). * * @param collectionName - Name of the collection or system entity * @param entryId - ID of the entry to fetch * @returns The entry or null if not found */ fetchRelatedEntry(collectionName: string, entryId: string, access?: RelatedRowAccess): Promise | null>; /** * Read ONLY the related target ids from the junction table, on the supplied * executor. Unlike {@link fetchManyToManyRelations}, it does not materialize * the target rows through the pool — so a caller building a snapshot inside a * write transaction sees a target created earlier in that same transaction * (whose row is not yet visible on a pooled connection), and a single- * connection pool never stalls waiting for a second connection. The ids are * exactly what the junction stores, so nothing about the targets is needed. * * @param sourceCollectionName - Name of the source collection * @param sourceEntryId - ID of the source entry * @param field - Field definition * @param executor - Transaction-bound executor to read the junction on * @returns The related target ids (empty on any read failure — the caller that * requires completeness fails the write itself) */ fetchManyToManyTargetIds(sourceCollectionName: string, sourceEntryId: string, field: FieldDefinition, executor?: RelationshipDbExecutor): Promise; /** * Fetch many-to-many related entries. * Optimized with MySQL-compatible IN clause. * * @param sourceCollectionName - Name of the source collection * @param sourceEntryId - ID of the source entry * @param field - Field definition * @returns Array of related entries */ fetchManyToManyRelations(sourceCollectionName: string, sourceEntryId: string, field: FieldDefinition, executor?: RelationshipDbExecutor, access?: RelatedRowAccess): Promise[]>; /** * Insert many-to-many relationships into junction table. * Uses individual inserts for reliability (still fast with proper indexing). * * @param sourceCollectionName - Name of the source collection * @param sourceEntryId - ID of the source entry * @param field - Field definition * @param relatedIds - Array of related entry IDs to link * @param executor - Optional transaction-scoped Drizzle handle (from * `tx.getDrizzle()`); when provided, the junction existence check and * inserts run inside the caller's transaction so they commit atomically * with the entry write instead of always hitting the pool. */ insertManyToManyRelations(sourceCollectionName: string, sourceEntryId: string, field: FieldDefinition, relatedIds: string[], executor?: RelationshipDbExecutor): Promise; /** * Delete many-to-many relationships from junction table. * Uses Drizzle's sql tagged template for type safety and MySQL compatibility. * * @param sourceCollectionName - Name of the source collection * @param sourceEntryId - ID of the source entry * @param field - Field definition * @param executor - Optional transaction-scoped Drizzle handle; when * provided, the delete runs inside the caller's transaction instead of the * pool (see `insertManyToManyRelations` for the rationale). */ deleteManyToManyRelations(sourceCollectionName: string, sourceEntryId: string, field: FieldDefinition, executor?: RelationshipDbExecutor): Promise; /** * Get junction table name for many-to-many relationship. * * @param sourceCollectionName - Name of the source collection * @param targetCollectionName - Name of the target collection * @param field - Field definition * @returns Junction table name */ getJunctionTableName(sourceCollectionName: string, targetCollectionName: string, field: FieldDefinition): string; } /** * Base Registry Service * * Abstract base class for domain registry services (collections, singles, components). * Extracts shared CRUD query patterns, migration tracking, filter building, * and utility methods that are duplicated across all three registry services. * * Domain-specific registries extend this class and implement the abstract members * to specialize behavior (table name prefix, search columns, deserialization). * * @module shared/base-registry-service * @since 1.0.0 */ /** * Common fields for listing registry records with filters and pagination. * Domain-specific list options interfaces extend or mirror this shape. */ interface BaseListOptions { /** Filter by source type (e.g., "code", "ui", "built-in") */ source?: string; /** Filter by migration status */ migrationStatus?: string; /** Include only locked or unlocked records */ locked?: boolean; /** Search query for filtering by slug or label */ search?: string; /** * Restrict results to records whose `slug` is in this list. * * Used to scope queries to a per-user permission allowlist so that both * the row results AND the `total` count reflect what the caller is * actually allowed to see. Without this, callers that filter rows in * application code after a paginated fetch end up with an inflated * `total` (leaks counts of hidden records) and `hasNext` flags that drive * clients into wasted pagination loops. * * Semantics: * - `undefined` (default) means "no allowlist filter applied". * - `[]` means "no records are visible" and short-circuits to an empty * result with `total: 0`. This is preferred over relying on * dialect-specific `IN ()` behaviour. */ slugAllowlist?: string[]; /** Maximum number of results */ limit?: number; /** Number of results to skip */ offset?: number; } /** * Paginated list result with total count. */ interface BaseListResult { /** Records for the current page */ data: TRecord[]; /** Total count of matching records (before pagination) */ total: number; } /** * Minimum shape that all registry records share. * Used as a constraint on the TRecord generic parameter. */ interface BaseRegistryRecord { id: string; slug: string; tableName: string; locked: boolean; migrationStatus: string; } /** * Abstract base class for domain registry services. * * Provides shared implementations for: * - Query methods: getBySlug, getOrThrow, getAll, list * - Migration tracking: updateMigrationStatus, updateMigrationStatusWithVerification, getPendingMigrations * - Locking: isLocked * - Utilities: generateId, computeSimpleHash, generateTableName, ensureTableNamePrefix, adminConfigChanged * - Filter building: source, migrationStatus, locked, and search conditions * * @typeParam TRecord - The full record type (must extend BaseRegistryRecord) * @typeParam TMigrationStatus - The migration status union type for this domain */ declare abstract class BaseRegistryService extends BaseService { constructor(adapter: DrizzleAdapter, logger: Logger); /** The metadata table name (e.g., "dynamic_collections"). */ protected abstract readonly registryTableName: string; /** * The metadata table to address for this call. * * Separate from {@link registryTableName} because one registry's table can be * renamed under a running database: the field-group storage migration moves * it, so its name is an observation rather than a constant there. Every query * below goes through this, and the default answers with the declared name, so * a registry whose table never moves costs nothing and reads identically. */ protected resolveRegistryTableName(): Promise; /** Human-readable resource type for error messages (e.g., "Collection"). */ protected abstract readonly resourceType: string; /** Table name prefix for this domain (e.g., "dc_", "single_", "comp_"). */ protected abstract readonly tableNamePrefix: string; /** Column names to search via ILIKE when `search` is provided in list options. */ protected abstract getSearchColumns(): string[]; /** Deserialize a raw DB row into the typed record. */ protected abstract deserializeRecord(record: TRecord | Record): TRecord; /** * Get a record by slug, returning null if not found. */ protected getRecordBySlug(slug: string, executor?: unknown): Promise; /** * Get a record by slug, throwing NOT_FOUND if missing. * * §13.8: public message is generic; identifying details (slug, resource * type) flow through `logContext`, not the wire. */ protected getRecordOrThrow(slug: string, executor?: unknown): Promise; /** * Get all records, optionally filtered by source, migration status, and locked. */ protected getAllRecords(options?: BaseListOptions): Promise; /** * List records with pagination, search, and total count. */ protected listRecords(options?: BaseListOptions): Promise>; /** * Check if a record is locked (code-first resources are locked). */ protected checkIsLocked(slug: string): Promise; /** * Update migration status for a record. */ protected updateRecordMigrationStatus(slug: string, status: TMigrationStatus, migrationId?: string): Promise; /** * Safely update migration status to 'applied' with table existence verification. * * CRITICAL: Use this instead of updateRecordMigrationStatus when setting status * to 'applied' to prevent the race condition where status is marked as 'applied' * but the table doesn't actually exist. */ protected updateMigrationStatusWithTableVerification(slug: string, tableName: string): Promise<{ verified: boolean; status: TMigrationStatus; }>; /** * Get all records with pending migrations (status 'pending' or 'generated'). */ protected getRecordsWithPendingMigrations(): Promise; /** * Generate a unique ID using crypto.randomUUID(). */ protected generateId(): string; /** * Compute a simple hash from a string (for auto-generating schema_hash). * Uses a fast DJB2-style hash — not cryptographic, just for change detection. */ protected computeSimpleHash(input: string): string; /** * Generate a table name from a slug. * Converts slug to snake_case, removes invalid characters, and adds the domain prefix. */ protected generateTableName(slug: string): string; /** * Ensure table name has the domain-specific prefix. */ protected ensureTableNamePrefix(tableName: string): string; /** * Check if admin config has changed between code and database. * Uses JSON comparison to detect changes in admin properties. */ protected adminConfigChanged(codeAdmin: unknown, existingAdmin: unknown): boolean; /** * Build WHERE conditions for source, migrationStatus, and locked filters. * Returns a mutable array so callers can add additional conditions (e.g., search). */ private buildFilterConditions; } /** * A reference to a Component from a Collection, Single, or another Component. */ interface ComponentReference { entityType: "collection" | "single" | "component"; entitySlug: string; fieldName: string; fieldPath: string; } interface UpdateComponentOptions { source?: FieldGroupSource; /** * Advance `schema_version` even though this write carries no new shape. * * For the caller whose DDL already landed and whose row write then failed: the tables moved, so * every editor loaded before that moment is now describing a shape that no longer exists. * `assertSchemaVersionMatch` is the only thing standing between such an editor and an apply * against the moved tables, and it compares versions — so a divergence that leaves the version * untouched lets a stale preview pass the optimistic lock. * * The caller states the INTENT and the registry still owns the arithmetic; letting a caller * supply the number would let one regress it. */ invalidateSchemaVersion?: boolean; } /** * Input for registering a code-first Component during sync. * * Carries no table name: the sync derives the physical name from the slug, so a * caller-supplied one could only disagree with the table the schema layer * creates. */ interface CodeFirstComponentConfig { slug: string; label: string; fields: DynamicFieldGroupInsert["fields"]; description?: string; admin?: FieldGroupAdminOptions; configPath?: string; /** i18n: whether the component is localized (translatable fields → companion table). */ localized?: boolean; } interface SyncComponentResult { created: string[]; updated: string[]; unchanged: string[]; errors: Array<{ slug: string; error: string; }>; } interface ListComponentsOptions extends BaseListOptions { source?: FieldGroupSource; migrationStatus?: FieldGroupMigrationStatus$1; } /** * Result of listing components with pagination info. * * Declared as a `type` alias rather than an empty `interface` because the latter * triggers @typescript-eslint/no-empty-object-type. The named export is preserved * for clearer call-site semantics even though it adds no members today. */ type ListComponentsResult = BaseListResult; interface EnrichedComponentSchema { label: string; fields: Record[]; admin?: FieldGroupAdminOptions; } interface EnrichedFieldConfig extends Record { name?: string; type?: string; componentFields?: Record[]; componentSchemas?: Record; } declare class FieldGroupRegistryService extends BaseRegistryService { protected readonly registryTableName: "dynamic_components"; protected readonly resourceType = "Component"; protected readonly tableNamePrefix: "comp_"; constructor(adapter: DrizzleAdapter, logger: Logger); /** * The registry table this database actually holds. * * Unlike the collection and single registries, this one is renamed by the * field-group storage migration, so the declared name above is what a * database has *before* that runs and not a fact about the database in front * of us. Resolved from the catalog and memoized per adapter, so the answer * costs one catalog read per process rather than one per query. */ protected resolveRegistryTableName(): Promise; protected getSearchColumns(): string[]; getComponentBySlug(slug: string, executor?: unknown): Promise; getComponent(slug: string, executor?: unknown): Promise; getAllComponents(options?: ListComponentsOptions): Promise; listComponents(options?: ListComponentsOptions): Promise; isLocked(slug: string): Promise; updateMigrationStatus(slug: string, status: FieldGroupMigrationStatus$1, migrationId?: string): Promise; updateMigrationStatusWithVerification(slug: string, tableName: string): Promise<{ verified: boolean; status: FieldGroupMigrationStatus$1; }>; getPendingMigrations(): Promise; /** * Register a new Component in the registry. * * @throws NextlyError(DUPLICATE) if a Component with the same slug already exists. * @throws NextlyError(DATABASE_ERROR) on insert failure. */ registerComponent(data: DynamicFieldGroupInsert): Promise; registerComponentInTransaction(tx: TransactionContext, data: DynamicFieldGroupInsert): Promise; /** * Update a Component's metadata. * * @throws NextlyError(NOT_FOUND) when no Component matches the slug. * @throws NextlyError(FORBIDDEN) when the Component is locked and the source isn't "code". */ updateComponent(slug: string, data: Partial, options?: UpdateComponentOptions): Promise; /** * The column writes an update carries, shared by the unconditional and the conditional update. * * Everything EXCEPT `schema_version`: the two callers derive the version differently — one from * the row it just read, one from the version its caller's decision was computed against — and * that arithmetic is the whole difference between them, so it stays at the call sites while the * column mapping lives once here. */ private buildComponentUpdateColumns; /** * Update a Component only if its `schema_version` is still the one the caller decided from. * * A compare-and-set: the WHERE clause carries the expected version, so the DATABASE decides in * one statement whether the row still is what the caller believed — there is no read between the * decision and the write for another writer, or a transient failure, to slip through. The two * callers this exists for both hold a decision computed against a version they read earlier: a * divergence marker that must not stamp a row the original write reached after all, and a * reconcile whose repair must not overwrite an edit that landed while it was planning. * * `{ matched: false }` is a THIRD outcome, not an error: the row at that version no longer * exists, because it advanced or because it was deleted. The two are indistinguishable without * another read — which is exactly the dependency this method removes — and every caller treats * them the same way: the decision is stale, do not write, re-derive. It is not squeezed into the * NOT_FOUND throw, whose meaning here would be false for the commoner (advanced) case. * * The version ALWAYS advances, to `expectedSchemaVersion + 1`, computed against the value the * WHERE pins rather than a fresh read. * * 🔴 That advance is also what keeps the matched count trustworthy on MySQL, which counts CHANGED * rows rather than matched ones: a matching write always moves `schema_version`, so matched * implies changed — see `DrizzleAdapter.updateCount`. `updated_at` moves on every write too and * masks the distinction in ordinary use, which is why the version is the one to rely on: it is * strictly monotonic, while two writes inside a single timestamp tick carry the SAME `updated_at` * and would leave an all-identical payload counting zero. Do not remove either without the other. * * No returned row, deliberately. On a dialect without RETURNING a returning read re-runs the * WHERE — whose version this write just moved past — so a landed write would read back as * missing. The caller already knows the whole write it requested; there is nothing a read-back * could add except a second query able to fail after the first committed. */ updateComponentIfVersion(slug: string, data: Partial, expectedSchemaVersion: number, options?: UpdateComponentOptions): Promise<{ matched: true; newSchemaVersion: number; } | { matched: false; }>; /** * Delete a Component from the registry. */ deleteComponent(slug: string): Promise; private dropComponentTable; /** * Sync code-first Components with the registry. */ syncCodeFirstComponents(configs: CodeFirstComponentConfig[]): Promise; /** * Write the config file's definition onto a component the registry already holds. * * Extracted so the divergence repair above reads as one step rather than being buried in the * branch it guards; the decision of WHAT to write is unchanged. */ private syncExistingCodeFirstComponent; /** * Find all references to a Component across Collections, Singles, and other Components. */ findComponentReferences(componentSlug: string): Promise; /** * Enrich field configurations with inline component schemas. */ enrichFieldsWithComponentSchemas(fields: Record[], currentDepth?: number): Promise; private collectComponentSlugs; private fetchComponentsBySlugsBatch; private enrichFieldsRecursive; private parseJsonField; private scanFieldsForComponentRef; protected deserializeRecord(record: DynamicFieldGroupRecord | Record): DynamicFieldGroupRecord; } /** * Parameters for saving component data as part of a parent entry operation. */ interface SaveComponentDataParams { /** UUID of the parent entry */ parentId: string; /** Database table name of the parent entity (e.g., 'dc_pages', 'single_homepage') */ parentTable: string; /** Field definitions of the parent entity (to detect component fields) */ fields: FieldConfig[]; /** The full data object from the parent entry (contains component field values) */ data: Record; /** * i18n: write locale. When set and an embedded component is localized, its translatable * field values are written to the component's companion `_locales` row for this locale * (shared fields still go to the main comp_ row). Threaded from the parent entity's write. */ locale?: string; /** * The parent write's request context, forwarded to the field validators that run on each * component instance. Carries `user` when the write is authenticated. * * A component instance is validated by its own pass, in its own service, against its own * field set — the parent entry's validation never reaches inside it. Without this the * instance pass runs with an empty context, so a field rule that reads `req.user` cannot * tell an authenticated write from an anonymous one and accepts both. * * The whole record rather than a bare `user`: it is what the validator receives, so * anything else the parent write puts on its request travels with it. */ req?: Record; } /** * Parameters for deleting all component data when a parent entry is removed. */ interface DeleteComponentDataParams { parentId: string; parentTable: string; fields: FieldConfig[]; } /** * Parameters for populating component data on a single parent entry. */ /** * The caller context a component's related rows are judged against, mirroring * the relationship service's own options so it can be forwarded unchanged. */ /** * The caller context a component's related rows are judged against. * * A relationship reached through a field group is populated by the same service * a top-level one is, so it carries the same context rather than a parallel * declaration of it. */ type ComponentReadAccess = RelatedRowReadContext; interface PopulateComponentDataParams { /** The entry to populate with component data */ entry: Record; /** Database table name of the parent entity (e.g., 'dc_pages', 'single_homepage') */ parentTable: string; /** Field definitions of the parent entity (to detect component fields) */ fields: FieldConfig[]; /** * Depth for relationship/upload field expansion within component data. * 0: Return IDs only. 1+: Recursive expansion. * @default 2 */ depth?: number; /** * Current depth level (internal use for recursive expansion). * @internal */ currentDepth?: number; /** * Field selection whitelist for optimization. * When provided, only component fields with `select[fieldName] === true` are populated. */ select?: Record; /** * i18n: requested read locale. When set and an embedded component is localized, its * translatable fields resolve per language from the component's companion `_locales` table * (with fallback). Threaded down from the parent entity's read. */ locale?: string; /** * i18n: per-request fallback control, forwarded from the parent read's `?fallback-locale`. * `false`/`"none"` suppresses fallback so an untranslated embedded component field stays * blank (the admin's no-fallback edit mode); a named locale overrides the configured chain. */ fallbackLocale?: string | false; /** * Optional transaction-bound executor. When supplied, component-table reads * run on the transaction's connection (read-your-writes, #226) so a version * snapshot assembled inside the write transaction sees the components just * written in it. Omitted for ordinary reads (pooled connection). */ executor?: unknown; /** * When true, a component read failure propagates instead of being caught and * replaced with a default (`null`/`[]`). Callers whose result feeds a durable * write — a webhook payload or version snapshot assembled inside the write * transaction — set this so a real read failure rolls the write back rather * than shipping a payload with silently-missing component data. */ strict?: boolean; /** * The caller a related row reached THROUGH this component is redacted for. * * Component population expands the component's own relationship fields, which * copy whole rows out of the target collection. Neither the parent entity's * field registry nor the component's describes that collection's fields, so * without the caller here a field the target collection protects is returned * inside the populated component to anyone who reads the parent. * * Enforcement is opt-in for the reason established in the relationship * service: a caller that supplies no context is indistinguishable from an * anonymous one, and enforcing for the former strips fields from everybody. * The read paths opt in; write-side callers assembling payloads do not. */ access?: ComponentReadAccess; } /** * Parameters for populating component data on multiple parent entries (batch). */ interface PopulateComponentDataManyParams { entries: Record[]; parentTable: string; fields: FieldConfig[]; depth?: number; currentDepth?: number; select?: Record; /** i18n: requested read locale (see PopulateComponentDataParams.locale). */ locale?: string; /** i18n: per-request fallback control (see PopulateComponentDataParams.fallbackLocale). */ fallbackLocale?: string | false; /** See PopulateComponentDataParams.access. */ access?: ComponentReadAccess; } declare class FieldGroupDataService { private readonly registryService; private readonly queryService; private readonly mutationService; constructor(adapter: DrizzleAdapter, logger: Logger, registryService: FieldGroupRegistryService, relationshipService?: CollectionRelationshipService, localization?: SanitizedLocalizationConfig); /** * The component's own field definitions, resolved from the registry so * Schema-Builder components (which exist only in the database) are covered as * well as config-defined ones. Callers that must reason about fields nested * inside a component reference use this rather than reaching for the registry * directly. Returns null when the component is unknown. * * `executor` is forwarded so a caller already inside a write transaction can * read on that transaction's connection. Without it the lookup takes a * second pooled connection while the transaction still holds its own, which * stalls against a small pool. */ getComponentFields(slug: string, executor?: unknown): Promise; /** * Whether the component's OWN definition is localized — i.e. its translatable * field values route to the per-locale companion (`comp__locales`) * table. Mirrors the storage gate in the component mutation service * (`meta.localized !== true` keeps all data on the shared main table * regardless of inner field types), so a caller can tell a per-locale * component write apart from a shared one without re-deriving it from the * inner field types. */ isComponentLocalized(slug: string, executor?: unknown): Promise; /** * The component's physical table name as recorded in the registry. * * Callers that need to address a component's storage directly (a filter * subquery, for instance) must go through this rather than re-deriving the * name: a row written before names resolved canonically can still point at a * table the slug does not reconstruct. Returns null when the component is * unknown. */ getComponentTableName(slug: string, executor?: unknown): Promise; setRelationshipService(service: CollectionRelationshipService): void; saveComponentData(params: SaveComponentDataParams): Promise; saveComponentDataInTransaction(tx: TransactionContext, params: SaveComponentDataParams): Promise; /** * Verify every localized field group in a payload can be written, BEFORE the caller opens its * transaction. See {@link FieldGroupMutationService.assertLocalizedFieldGroupsWritable} — this * cannot run inside the transaction without risking pool starvation, and answering it first * keeps a refusal exactly as raised. * * Not optional bookkeeping: it is also what resolves each field group's readiness, which the * in-transaction write then reads. Skipping it leaves the write with no way to learn whether a * companion exists short of probing for one, which aborts the transaction on PostgreSQL when it * does not. */ assertLocalizedFieldGroupsWritable(params: Pick): Promise; deleteComponentData(params: DeleteComponentDataParams): Promise; deleteComponentDataInTransaction(tx: TransactionContext, params: DeleteComponentDataParams): Promise; populateComponentData(params: PopulateComponentDataParams): Promise>; populateComponentDataMany(params: PopulateComponentDataManyParams): Promise[]>; } /** * Webhook domain — delivery (delivery rows to HTTP requests). * * The drain's second phase. Fan-out turns events into `nextly_webhook_deliveries` * rows; this claims the rows that are due, signs and sends each one over the * SSRF-safe transport, and records the outcome — marking the delivery delivered, * scheduled for a jittered retry, or permanently failed per {@link decideDelivery}. * * Concurrency: a delivery is claimed with a short lease (`locked_by`/`locked_until`) * taken inside its own transaction, then the HTTP request runs with NO transaction * open (a network call must never hold a DB lock). The lease keeps a second drain * off the row for the request window; SQLite's single-writer transactions make the * claim exclusive, and on Postgres/MySQL the lease makes a concurrent double-send * rare and harmless (deliveries carry the Standard Webhooks `webhook-id`, so a * conformant receiver dedupes). A stronger `FOR UPDATE SKIP LOCKED` claim is a * follow-up gated on the adapter growing a row-locking primitive. * * @module domains/webhooks/deliver */ /** The transaction surface the lease claim needs (subset of the adapter tx). */ interface DeliverTx { select(table: string, options?: SelectOptions): Promise; update(table: string, data: Record, where: { and: Array<{ column: string; op: string; value: unknown; }>; }): Promise; } /** The database surface `deliverDueDeliveries` needs (satisfied by the adapter). */ interface DeliverDatabase { select(table: string, options?: SelectOptions): Promise; update(table: string, data: Record, where: { and: Array>; }, options?: { returning?: boolean; }): Promise; transaction(fn: (tx: DeliverTx) => Promise): Promise; } /** Minimal logger surface; delivery warns on undeliverable rows. */ interface DeliverLogger { warn(message: string, context?: unknown): void; } /** * The HTTP transport. Defaults to the SSRF-safe {@link safeFetch}; injectable so * tests can drive outcomes without real network access. */ type DeliverTransport = (url: string, options: { method: string; headers: Record; body: string; maxResponseBytes: number; timeoutMs: number; }) => Promise; interface DeliverDeps { db: DeliverDatabase; /** * Decrypt one stored signing secret (the `secret_hash` column holds AES-GCM * ciphertext, not a hash). Injected so the engine never reads `env` directly * and stays unit-testable; the route wiring passes * `ct => decrypt(ct, env.NEXTLY_SECRET)`. */ decryptSecret: (ciphertext: string) => string; /** HTTP transport. Defaults to {@link safeFetch}. */ transport?: DeliverTransport; /** Max deliveries to claim this pass. Defaults to 50. */ batchSize?: number; /** Lease duration held on a claimed row while its request is in flight (ms). */ leaseMs?: number; /** Per-request timeout in ms. Defaults to 15s. */ requestTimeoutMs?: number; /** * Wall-clock cutoff for this pass. Once reached, no further due delivery is * claimed or attempted; an already in-flight attempt still completes (bounded * by `requestTimeoutMs`). Lets a latency-bounded trigger — a serverless cron * tick — stop cleanly instead of running a full batch of hung receivers to * completion; the leftover rows are picked up on the next pass. Unbounded when * unset. */ deadline?: Date; /** Clock; injectable for deterministic tests. */ now?: () => Date; /** Unique id for this drain runner, recorded as the lease owner. */ runnerId?: string; logger?: DeliverLogger; } /** * Webhook domain — the stored signing-secret entry shape and its lifecycle. * * `secret.ts` owns the crypto (generate, encrypt, decrypt, display prefix). This * module owns the SHAPE of what a `nextly_webhooks.secret_hash` cell holds and * the rules for a rotation with an overlap window. * * The column is a JSON array so rotation is additive without a migration. It * started life as an array of bare ciphertext strings; this module widens each * entry to carry its display prefix and lifecycle timestamps, and reads the old * bare-string form back transparently so existing endpoints keep signing without * a data migration. * * A secret with `expiresAt === null` is the live primary — the one new * deliveries are prefixed by and the one a reveal reports first. A rotation * stamps the previous primary with an `expiresAt` in the future (the overlap * window) so a receiver that has not yet switched still verifies; once that * passes the entry is no longer live and is pruned on the next write. * * @module domains/webhooks/secret-entries */ /** * One stored signing secret. `ciphertext` is the AES-GCM value `secret.ts` * produces; `prefix` is the display-only fragment shown in the admin so an * operator can tell secrets apart during a rotation; `createdAt`/`expiresAt` are * ISO-8601 instants. `expiresAt === null` marks the live primary. */ interface StoredSecretEntry { ciphertext: string; prefix: string; createdAt: string; expiresAt: string | null; } /** * Webhook domain — pure types. * * The public delivery contract for the durable-outbox webhook system: the * event envelope that ships to endpoints, the endpoint registry shape, and the * structured filter spec. These are storage-agnostic; the per-dialect Drizzle * tables (`schemas/webhooks/*`) persist them, and the delivery engine (later * slices) reads them back. No I/O lives here. * * @module domains/webhooks/types */ /** * Canonical webhook event types, grouped by resource. Stable string ids; the * envelope's `specversion` (not renames) carries breaking changes. A webhook * subscribes to a set of these. Distinct from the internal event-bus names * (`document.*`, `collection..*`); the bus -> webhook-type mapping is * wired in the capture slice. */ declare const WEBHOOK_EVENT_TYPES: readonly ["entry.created", "entry.updated", "entry.deleted", "entry.published", "entry.unpublished", "entry.status_changed", "single.updated", "single.published", "single.unpublished", "media.uploaded", "media.updated", "media.deleted", "user.created", "user.deleted", "form.submission.created"]; /** * Wildcard subscription token. An endpoint subscribed to this receives every * event type, including types added in future versions — so a "subscribe to * all" endpoint keeps working as the catalog grows, without a config edit. It * is a subscription concept only; the finer `FilterSpec` never uses it. */ declare const WEBHOOK_EVENT_WILDCARD = "*"; /** A canonical webhook event type. */ type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; /** * What an endpoint can subscribe to: a specific event type or the wildcard * (all-and-future). Distinct from {@link WebhookEventType} because only the * subscription list accepts the wildcard. */ type WebhookEventSubscription = WebhookEventType | typeof WEBHOOK_EVENT_WILDCARD; /** * Structured, extensible per-webhook filter. v1 is a plain conjunction of * optional constraints; a future expression filter is an additive * discriminated member on the same `version` column, so v1 -> v2 needs no * migration. Evaluated by the pure `matchesFilter`. */ interface FilterSpecV1 { version: 1; /** OR across types; absent/empty = every subscribed type matches. */ eventTypes?: WebhookEventType[]; /** null/absent = all collections. */ collections?: string[] | null; /** Fire only if any listed field changed; null/absent = no constraint. */ changedFields?: string[] | null; } /** Reserved for the future expression filter (not yet evaluated). */ interface FilterSpecExpression { version: 2; type: "expression"; expr: string; } type FilterSpec = FilterSpecV1 | FilterSpecExpression; /** * The outbound endpoint registry shape (mirrors `nextly_webhooks`). Secrets are * never held raw: `secretHash` is the list of active-secret hashes (a list for * zero-downtime rotation) and `secretPrefix` is a display-only prefix. The * property name matches the Drizzle column so a hydrated row maps directly. */ interface WebhookEndpoint { id: string; name: string; url: string; enabled: boolean; /** Subscribed event types, or the wildcard for all-and-future. */ eventTypes: WebhookEventSubscription[]; /** Structured filter, or null for "match every subscribed type". */ filter: FilterSpec | null; /** Static request headers merged into every delivery. */ headers: Record | null; /** * Stored signing-secret entries (list-shaped for rotation). Carried for * completeness; the delivery path reads and signs from its own row rather * than this cached copy. */ secretHash: StoredSecretEntry[]; secretPrefix: string; /** Reserved per-endpoint field projection; not applied yet. */ fieldAllowlist: string[] | null; createdBy: string | null; createdAt: Date; updatedAt: Date; } /** * Webhook domain — enabled-endpoint registry. * * Provides the set of enabled endpoints the drain fans events out to, cached in * memory so a drain pass loads them once rather than per event. The cache is * loaded lazily on first use and dropped by `invalidate()` — the webhook CRUD * surface (a later slice) calls that on create/update/delete so changes take * effect without a restart. * * @module domains/webhooks/endpoint-registry */ /** The narrow read surface the registry needs (satisfied by the DB adapter). */ interface WebhookEndpointReader { select(table: string, options?: { where?: { and: Array<{ column: string; op: string; value: unknown; }>; }; }): Promise; } declare class WebhookEndpointRegistry { private readonly reader; private cache; private cachedAtMs; private inFlight; private inFlightGeneration; private generation; private readonly ttlMs?; private readonly now; /** * `invalidate()` handles same-process CRUD changes. `ttlMs` additionally * bounds staleness from OTHER processes (a webhook created/enabled elsewhere * that can't call this instance's `invalidate()`): after `ttlMs` the next read * reloads. Omit it (the default) to cache until `invalidate()` — appropriate * for a short-lived registry built fresh per drain run. */ constructor(reader: WebhookEndpointReader, options?: { ttlMs?: number; now?: () => number; }); private isExpired; /** Enabled endpoints, loaded once and cached until `invalidate()` (or TTL). */ getEnabledEndpoints(): Promise; /** Drop the cache so the next read reloads from the database. */ invalidate(): void; /** * Enabled endpoints read fresh from the database, bypassing the TTL cache. * * Fan-out uses this rather than {@link getEnabledEndpoints}: a fanned-out * event is marked done permanently and never reconsidered, so serving a stale * cross-process list — an endpoint another instance created within the TTL — * would drop that new subscriber's deliveries forever. Correctness there beats * saving a per-round query; delivery and other readers can still use the cache. */ getEnabledEndpointsFresh(): Promise; /** * Whether any enabled endpoint exists, from the same cached list the drain * reads (so this shares its invalidation and TTL). The recording gate's * presence flag is refreshed from this on a pooled connection, never inside a * content write transaction. */ hasEnabledEndpoints(): Promise; private load; } /** * Webhook domain — fan-out (events to delivery rows). * * The drain's first phase. `recordEvent` writes only the durable event; this * turns each un-fanned event into per-endpoint `nextly_webhook_deliveries` * rows. Splitting fan-out out of the content transaction is the transactional * outbox pattern: content writes never touch the webhook registry, so a webhook * being created, disabled, or deleted can never fail an unrelated content write. * * Fan-out is idempotent under concurrent drains: each event is fanned out in * its own transaction that reads the deliveries already present and inserts only * the missing ones, and the unique `(webhook_id, event_id)` index is the hard * backstop. If two drains race the same event, the loser's transaction rolls * back and the event is simply retried on the next pass. An event with no * matching endpoint is still marked fanned out (it needs no delivery). * * @module domains/webhooks/fan-out */ /** The transaction surface `fanOutDueEvents` needs (subset of the adapter tx). */ interface FanOutTx { select(table: string, options?: SelectOptions): Promise; insertMany(table: string, data: Record[]): Promise; update(table: string, data: Record, where: { and: Array<{ column: string; op: string; value: unknown; }>; }): Promise; } /** The database surface `fanOutDueEvents` needs (satisfied by the adapter). */ interface FanOutDatabase { select(table: string, options?: SelectOptions): Promise; transaction(fn: (tx: FanOutTx) => Promise): Promise; } /** Minimal logger surface; fan-out only warns on a deferred event. */ interface FanOutLogger { warn(message: string, context?: unknown): void; } interface FanOutDeps { db: FanOutDatabase; /** Loads the enabled endpoints once per pass (e.g. a `WebhookEndpointRegistry`). */ loadEndpoints: () => Promise; /** Max events to claim this pass. Defaults to 100. */ batchSize?: number; /** Clock; injectable for deterministic tests. */ now?: () => Date; /** Delivery id generator; injectable for deterministic tests. */ newId?: () => string; logger?: FanOutLogger; } /** User-facing retention options. `false` anywhere means "keep forever". */ interface WebhookRetentionConfig { /** Age after which a webhook-class event is prunable. `false` = keep forever. */ eventsMaxAgeMs?: number | false; /** * Age after which an audit-class event is prunable. `false` = keep forever. * Separate from `eventsMaxAgeMs` because audit history is measured in months * while outbox hygiene is measured in days. */ auditEventsMaxAgeMs?: number | false; /** * Age after which a TERMINAL delivery row is prunable. `false` = keep forever. * Clamped to at most the event windows: deliveries cascade from their event, * so a delivery can never outlive it and a larger value would be a lie. */ deliveriesMaxAgeMs?: number | false; /** * Rows deleted per statement. Clamped to {@link MAX_BATCH_SIZE}, above which a * pass would exceed SQLite's bind-parameter limit and fail every time. */ batchSize?: number; /** Batches per pass, so one pass stays bounded on a serverless request. */ maxBatchesPerRun?: number; /** Minimum spacing between passes. */ intervalMs?: number; } /** Retention fully resolved; every field is present and safe to use. */ interface ResolvedWebhookRetentionConfig { eventsMaxAgeMs: number | false; auditEventsMaxAgeMs: number | false; deliveriesMaxAgeMs: number | false; batchSize: number; maxBatchesPerRun: number; intervalMs: number; } /** * Webhook domain — retention pruning. * * Deletes aged rows from `nextly_events` and `nextly_webhook_deliveries` in * bounded batches. Never runs inside a content write's transaction: pruning is * housekeeping, and failing a user's save because it hiccuped is the wrong * trade. (Version retention deliberately takes the opposite position, because a * violated version cap is a correctness bug rather than untidiness.) * * Two safety rules govern which events may go, both forced by the schema: * * 1. `fanned_out_at IS NULL` means the event still needs fan-out. Deleting one * would discard an event nobody ever delivered. * 2. `nextly_webhook_deliveries.event_id` cascades, so deleting an event takes * its delivery rows with it — including any still pending or retrying. An * event is therefore only prunable once every child delivery is terminal. * * Deletion is two round trips per batch (select ids, then delete by id) because * no single statement batches a delete across all three dialects: PostgreSQL has * no `DELETE ... LIMIT`, MySQL rejects a subquery against the delete target, and * SQLite only supports it when compiled with a non-default flag. Selecting ids * first also confines MySQL's locks to specific records rather than gap-locking * a range of the index that concurrent inserts need. * * @module domains/webhooks/prune */ /** The subset of the adapter this module needs, so tests can supply a double. */ interface PruneAdapter { select(table: string, options?: { where?: { and: WhereCondition[]; }; orderBy?: { column: string; direction: "asc" | "desc"; }[]; limit?: number; offset?: number; columns?: string[]; }): Promise; delete(table: string, where: { and: WhereCondition[]; }): Promise; } interface PruneDeps { adapter: PruneAdapter; /** Injectable so tests can pin the cutoff instead of sleeping. */ now?: () => Date; /** * Stop between batches once this passes. * * A budget bounds the WORK a pass does; this bounds the TIME it takes, and * only the second is what a serverless invocation is killed for. It also * keeps a sweep here from consuming the whole wall-clock allowance and * leaving none for the passes that run after it. */ deadline?: Date; logger?: Logger; } /** * Webhook domain — drain orchestrator. * * One drain pass = fan out due events into delivery rows, then attempt the due * deliveries. Both phases are individually bounded (one batch per call), so the * orchestrator loops until a full round makes no progress or a round cap is hit. * This is the unit a scheduled trigger (a cron route, `after()`) invokes; the * trigger itself is a separate slice. * * @module domains/webhooks/run-drain */ interface RunDrainDeps { fanOut: FanOutDeps; deliver: DeliverDeps; /** Max fan-out/deliver rounds before returning. Defaults to 100. */ maxRounds?: number; /** * Wall-clock budget for the whole drain. Once exceeded the loop stops starting * new rounds AND the delivery pass stops attempting new deliveries, so a * scheduler tick returns within roughly `maxDurationMs` plus one in-flight * request timeout even when receivers hang — instead of `batch × rounds × * timeout`. The durable outbox + delivery lease let the next tick continue. * Unbounded when unset (the default; content-write-driven callers do not need * it). Measured with the delivery clock so tests stay deterministic. */ maxDurationMs?: number; /** * Retention, when configured. The pass runs after delivery so it only ever * sees rows this drain has finished with, and it is gated so a frequently * invoked drain does not prune on every call. */ retention?: { /** * Absent when webhook retention is off. The trails are pruned on their own * policy either way, so switching one off does not silently switch off the * other's only full-budget trigger. */ policy?: ResolvedWebhookRetentionConfig; /** Absent when the audit trails are configured to keep everything. */ auditPolicy?: ResolvedAuditRetentionConfig; /** * Absent when no delivery-log policy was carried. * * Present here for the reason the audit policy is, and more urgently. Every * other trigger for the delivery log is a WRITE — a send, or a content * mutation — so an install that has gone quiet offers no pass at all, and * the rows from its final sends stay indefinitely under a window that reads * as bounded. A scheduled drain is the one trigger that keeps running when * nothing else does. */ emailPolicy?: ResolvedEmailRetentionConfig; prune: PruneDeps; gate: RetentionGateStore; }; } /** * Webhook domain — drain wiring. * * `runDrain` is pure orchestration over injected deps; this builds those deps * from the runtime adapter and the shared endpoint registry and runs one drain. * It is the single construction site the two triggers share — the cron/manual * `/api/webhooks/drain` route and the post-response `after()` fast path — so they * cannot drift in how the engine is assembled. * * The signing secret is decrypted via `decryptWebhookSecret`, which reads * `env.NEXTLY_SECRET` itself, so no secret is threaded through here. * * @module domains/webhooks/drain-runner */ /** * The database surface a drain needs: the fan-out and delivery database * interfaces the runtime adapter satisfies. Kept as the minimal intersection * (rather than the concrete adapter type) so a caller resolves it from the DI * container as exactly what the drain uses. */ type WebhookDrainDatabase = FanOutDatabase & DeliverDatabase; interface RunWebhookDrainOptions { /** HTTP transport override; the engine defaults to the SSRF-safe safeFetch. */ transport?: DeliverTransport; /** Clock override for deterministic tests. */ now?: () => Date; /** Max fan-out/deliver rounds before returning. */ maxRounds?: number; /** * Events fanned out per round. Lowering it (with `maxRounds`) is how a * latency-bounded trigger — a serverless cron tick — caps the work one * invocation does; the outbox is durable, so the next tick continues. */ fanOutBatchSize?: number; /** * Deliveries attempted per round before the next fan-out round. Unclaimed rows * wait for the next tick. */ deliverBatchSize?: number; /** * Wall-clock budget for the whole drain. The hard bound a latency-bounded * trigger relies on: the drain returns within about this plus one in-flight * request timeout even when receivers hang, so a serverless cron tick finishes * before its platform kills it and the next tick continues. */ maxDurationMs?: number; /** * Per-request delivery timeout. A cron trigger passes a shorter value than the * engine default so a single hung receiver cannot stretch the pass past the * budget by much. */ requestTimeoutMs?: number; /** Retention housekeeping, when the caller has a policy + gate to run it. */ retention?: RunDrainDeps["retention"]; /** * Signing-secret decryptor. Defaults to {@link decryptWebhookSecret}, which * reads `env.NEXTLY_SECRET`; injectable so a test can drive the delivery path * without a configured secret. */ decryptSecret?: (ciphertext: string) => string; } /** * Webhook domain — the post-response drain fast path. * * After a content write records an outbox event, this kicks a bounded drain via * Next.js `after()` so the first delivery attempt happens immediately instead of * waiting for the next scheduled trigger. `after()` runs the work once the * response is sent, so it adds no latency to the write. Everything degrades * gracefully: outside a Next request (a CLI or plain-Node write) or on a Next * version without `after`, this is a no-op and the scheduled drain delivers. * * @module domains/webhooks/after-drain */ /** Next.js `after()`: schedules a callback to run once the response is finished. */ type AfterFn = (callback: () => void | Promise) => void; /** * Schedules an immediate, bounded drain after a content write's response, when * the runtime supports `after()`. The subscriber check runs inside the scheduled * callback (after the response is sent), never in the write path, so a write is * never delayed by a registry read. A single instance is shared across every * write path; it holds only a single-flight flag so concurrent writes in one * process coalesce into one drain instead of racing. */ declare class WebhookFastDrainScheduler { private readonly adapter; private readonly registry; private readonly logger?; private readonly loadAfter; private readonly drainOptions; private cachedAfter; private draining; private rerunRequested; constructor(adapter: WebhookDrainDatabase, registry: WebhookEndpointRegistry, logger?: Logger | undefined, loadAfter?: () => AfterFn | null, drainOptions?: RunWebhookDrainOptions); /** * Schedule the drain to run after the response. Safe to call after every * write: it self-gates on runtime support and never throws — a failure here * must never turn a successful write into an error, and the scheduled drain is * always the backstop. It does NOT read the database: the subscriber check * happens inside the callback so the write path stays free of registry reads. * * Synchronous: it only registers the callback. The delivery work is owned by * `after()` (which survives the response via `waitUntil`), so there is nothing * for a caller to await. */ offer(): void; /** * The scheduled work: gate on there being a subscriber, then one bounded * drain. Both the gate and fan-out read endpoints fresh, so a subscriber * another process just created is seen. Absorbs its own failures — this runs * after the response, so there is nothing to fail. * * Single-flight: if a drain is already running in this process, record that a * trailing pass is wanted and return, so concurrent writes never launch racing * drains. The running drain then loops once more, and because fan-out reads * fresh each pass it picks up the events those trailing writes recorded. */ private drainIfSubscribed; } /** Caller-facing status filter override. */ type StatusOption = "published" | "draft" | "all"; /** * Singles Domain Types * * Shared type definitions used by SingleEntryService, SingleQueryService, * SingleMutationService, and SingleRegistryService. Extracted here so the * split services can reference the same interfaces without circular imports. * * @module domains/singles/types * @since 1.0.0 */ /** * User context for Single operations. */ interface UserContext$1 { /** User ID */ id: string; /** User email */ email?: string; /** Singular authorized role. */ role?: string; /** * Full authorized role set. The route path forwards the caller's decoded * roles so stored Single access rules (role-based) and the super-admin * bypass evaluate against the real authorized scope. */ roles?: string[]; /** Additional user properties */ [key: string]: unknown; } /** * Options for getting a Single document. */ interface GetSingleOptions { /** * Which collections a trusted read may reach as relationships are expanded, * asked per RELATED collection. Absent means every populated target inherits * the caller's trust, which is unchanged behaviour. Evaluated as * `overrideAccess && trusted(target)`, so it can only ever narrow. */ trusted?: (collection: string) => boolean; /** * Depth for relationship expansion. * @default 2 */ depth?: number; /** * Locale for localized fields. Translatable fields resolve to this language * (with fallback) from the companion `single__locales` table. */ locale?: string; /** * Fallback control (i18n). `false` / `"none"` disables fallback — an * untranslated field returns empty instead of the default-locale value (so * the admin editor can show blanks for a language that hasn't been * translated yet). Otherwise the configured fallback chain + default locale * is used. Mirrors the collection read path. */ fallbackLocale?: string | false; /** * When true, attach a per-locale `_translations` map (translated + status) * to the document for the admin's per-language status pills. No-op when the * Single isn't localized. Mirrors the collection read path. */ translationStatus?: boolean; /** User context for access control and hooks. */ user?: UserContext$1; /** * When true, bypass all RBAC access control checks. * @default true (when called via Direct API) */ overrideAccess?: boolean; /** * Set by a route whose middleware already authenticated AND authorized the * caller. Skips only the redundant RBAC re-check, which resolves permissions * from the caller's stored roles and would otherwise reject an API key whose * scoped permissions differ from its creator's. Stored access rules still run. */ routeAuthorized?: boolean; /** * The caller's authenticated scope. A scoped API key is judged on its OWN * read grant rather than the key owner's permissions, so a super-admin-owned * key does not skip a stored read rule. */ authenticatedScope?: AuthenticatedScope; /** * Draft/Published filter override. Only effective when single.status === true. * - 'published' (default for public/untrusted callers): only return the * document when its status is 'published'; otherwise return 404 (so a * draft Single is invisible until published). * - 'draft': only return when status is 'draft'. * - 'all': return regardless of status. * Trusted callers (overrideAccess: true) default to 'all' if unset. */ status?: StatusOption; /** Arbitrary data passed to hooks via context. */ context?: Record; } /** * Options for updating a Single document. */ interface UpdateSingleOptions$1 { /** * Which collections a trusted read may reach as relationships are expanded, * asked per RELATED collection. Absent means every populated target inherits * the caller's trust, which is unchanged behaviour. Evaluated as * `overrideAccess && trusted(target)`, so it can only ever narrow. */ trusted?: (collection: string) => boolean; /** * Set when this write restores an earlier version, recording which one on the * version it captures. Lineage cannot be inferred afterwards: a restore is an * ordinary write that happens to reproduce an earlier state. */ sourceVersionNo?: number; /** * Locale for localized fields. * Reserved for future i18n support. */ locale?: string; /** User context for access control and hooks. */ user?: UserContext$1; /** * Who performed the write, for webhook/audit attribution. The transport * boundary resolves it (distinguishing a signed-in user from an API key * acting on their behalf); when absent the recorder falls back to `user`. * Parity with the collection write path's actor. */ actor?: RequestActor; /** * When true, bypass all RBAC access control checks. * @default true (when called via Direct API) */ overrideAccess?: boolean; /** * Set by the REST dispatcher: route-level auth already ran, so `overrideAccess` * is used to skip the RBAC re-check — but this is NOT a trusted-server read, * so the response is still redacted to what the user may read. */ routeAuthorized?: boolean; /** Arbitrary data passed to hooks via context. */ context?: Record; /** * The caller's authenticated scope. For a scoped API-key REST write, the * publish/unpublish transition gate judges the key's OWN grants rather than * the key owner's RBAC. */ authenticatedScope?: AuthenticatedScope; /** * Skip cache revalidation for this write (the outbox drain still runs). Set by * callers that own their cache strategy — a CLI, seed, or bulk-import write. */ disableRevalidate?: boolean; } /** * Single document shape. All Singles have at least an id and updatedAt. */ interface SingleDocument { /** Document ID (UUID) */ id: string; /** Last update timestamp */ updatedAt: Date | string; /** Additional fields defined by the Single schema */ [key: string]: unknown; } /** * Result of a Single operation. */ interface SingleResult { /** Whether the operation succeeded */ success: boolean; /** HTTP status code */ statusCode: number; /** The Single document data (on success) */ data?: T; /** Error message (on failure) */ message?: string; /** * The failure's typed fields, so a boundary rebuilds the exact error instead * of guessing it from the status. Mirrors `CollectionServiceResult`. */ code?: string; messageKey?: string; publicData?: unknown; /** Error details (on failure) */ errors?: Array<{ field?: string; code?: string; message: string; }>; /** * Whether this write appended a durable outbox event, independent of * `success`. The update records the event inside its transaction, then runs * post-commit steps (afterChange/afterUpdate hooks, response expansion): if * one of those throws, the write is already committed but `success` is * reported `false`. Post-write side effects (the webhook fast-drain and * retention pass) key off this flag, not `success`, so a committed-but- * hook-failed write still gets its immediate delivery while a write that * recorded nothing (validation/access failure) does not. Mirrors * `CollectionServiceResult.eventRecorded`. */ eventRecorded?: boolean; /** * The cache tags this write invalidates (`nextly:single:{slug}` plus any * configured extra tags), flushed post-commit through the registered * revalidator. Absent when the write recorded nothing or revalidation is * disabled for the single. */ revalidationIntent?: RevalidationIntent; /** * Whether this update committed a content write to the database — true once the * row is written (even a write that opts out of BOTH recording and * revalidation, and even one whose post-commit hook then throws), false for a * rejected request or a no-op. The write-path retention pass keys off this so * it runs for every durable write yet skips one that changed nothing, without * conflating `success` (a no-op reports success) with a committed write. * Mirrors `CollectionServiceResult.committed`. */ committed?: boolean; } /** * Single Configuration Types * * Type definitions for Singles. * Singles are single-document entities for storing site-wide configuration * such as site settings, navigation menus, footers, and homepage configurations. * * Key differences from Collections: * - Only one document per Single (no list view) * - No create/delete operations (auto-created on first access) * - Simplified hooks (4 vs 8 for Collections) * - Simplified access control (read/update only) * * @module singles/config/types * @since 1.0.0 */ /** * Display label for a Single. * * Unlike Collections which have singular/plural forms, Singles only need * a singular label since there's always exactly one document. * * @example * ```typescript * const label: SingleLabel = { * singular: 'Site Settings', * }; * ``` */ interface SingleLabel { /** * Display name for the Single. * Used in the Admin UI sidebar, breadcrumbs, and page titles. * * @example 'Site Settings', 'Header Navigation', 'Footer' */ singular: string; } /** * Admin panel configuration options for a Single. * * Controls how the Single appears and behaves in the Admin UI. * Simpler than CollectionAdminOptions since there's no list view. * * @example * ```typescript * const admin: SingleAdminOptions = { * group: 'Settings', * icon: 'Settings', * description: 'Site configuration', * }; * ``` */ interface SingleAdminOptions { /** * Group name for organizing Singles in the sidebar. * Singles with the same group appear together under a common heading. * * @example 'Settings', 'Navigation', 'Content' */ group?: string; /** * Icon identifier for the Single. * Should be a valid icon name from the icon library (e.g., Lucide). * * @example 'Settings', 'Menu', 'Home', 'FileText' */ icon?: string; /** * Hide the Single from Admin UI navigation. * The Single is still accessible via direct URL and API. * * @default false */ hidden?: boolean; /** Sort order within sidebar group (lower = higher position, default: 100) */ order?: number; /** Custom sidebar group slug. When set, item moves from its default section to this custom group */ sidebarGroup?: string; /** * Description text displayed below the Single title. * Use this to provide helpful context for editors. * * @example 'Configure site settings like name, logo, and SEO defaults.' */ description?: string; } /** * Lifecycle hooks for Singles. * * Singles support a subset of Collection hooks since they only have * read and update operations (no create or delete). * * **Hook Execution Order for Read:** * 1. `beforeRead` - Before fetching from database * 2. Database read * 3. `afterRead` - After fetching, can transform data * * **Hook Execution Order for Update:** * 1. `beforeValidate` - Before the schema's rules are enforced * 2. Validation * 3. `beforeChange` - After validation, on the data about to be written * 4. Database update * 5. `afterChange` - After database write, for side effects * * The validation gate is what separates the two write hooks. Supply or repair a * value in `beforeValidate` if you want the rules applied to what you produced; * use `beforeChange` to derive the value that gets stored, knowing the document * has already passed them. * * @example * ```typescript * const hooks: SingleHooks = { * afterChange: [ * async ({ doc }) => { * // Revalidate frontend cache when settings change * await fetch('/api/revalidate?tag=site-settings', { method: 'POST' }); * }, * ], * }; * ``` */ interface SingleHooks { /** * Runs before reading the Single document. * Can modify query parameters or execute side effects. * * @example * ```typescript * beforeRead: [ * async ({ req }) => { * console.log(`User ${req.user?.id} reading settings`); * }, * ] * ``` */ beforeRead?: HookHandler[]; /** * Runs after reading the Single document. * Can transform the data before it's returned to the client. * * @example * ```typescript * afterRead: [ * async ({ doc }) => { * // Add computed property * return { ...doc, fullAddress: `${doc.street}, ${doc.city}` }; * }, * ] * ``` */ afterRead?: HookHandler[]; /** * Runs before the schema's declared rules are enforced. * * The phase for coercing input, or for supplying a value you want validated: * what it returns goes through the gate. Use it rather than `beforeChange` * whenever the write would fail validation without your handler. * * @example * ```typescript * beforeValidate: [ * async ({ data }) => { * // Normalize input before the rules are applied to it * return { ...data, siteName: data.siteName?.trim() }; * }, * ] * ``` */ beforeValidate?: HookHandler[]; /** * Runs after validation passes, before the database write. * * The phase for deriving the value that gets stored, on a document that has * already satisfied the schema. What it returns is written WITHOUT being * re-validated, so a handler that supplies a required field belongs in * `beforeValidate` instead -- by the time this runs, the write would already * have been rejected. * * @example * ```typescript * beforeChange: [ * async ({ data }) => { * // Derive a stored value from data already known to be valid * return { ...data, slugified: slugify(data.siteName) }; * }, * ] * ``` */ beforeChange?: HookHandler[]; /** * Runs after updating the Single document. * Useful for side effects like cache invalidation, notifications, etc. * * @example * ```typescript * afterChange: [ * async ({ doc }) => { * // Invalidate CDN cache * await invalidateCache(['site-settings', 'header', 'footer']); * }, * ] * ``` */ afterChange?: HookHandler[]; } /** * Complete Single configuration interface. * * This is the main interface for defining a Single in code. * Only `slug` and `fields` are required; all other properties have defaults. * * Singles are similar to Collections but simpler: * - Single document per Single (no list view) * - Auto-created on first access * - Only read/update operations (no create/delete) * - Table naming: `single_` prefix (e.g., `single_site_settings`) * * @example * ```typescript * import { defineSingle, text, upload, array, group } from 'nextly'; * * export default defineSingle({ * slug: 'site-settings', * label: { singular: 'Site Settings' }, * admin: { * group: 'Settings', * icon: 'Settings', * description: 'Site configuration', * }, * fields: [ * text({ name: 'siteName', required: true, label: 'Site Name' }), * text({ name: 'tagline', label: 'Tagline' }), * upload({ name: 'logo', relationTo: 'media', label: 'Logo' }), * group({ * name: 'seo', * label: 'SEO Defaults', * fields: [ * text({ name: 'metaTitle', label: 'Default Meta Title' }), * text({ name: 'metaDescription', label: 'Default Meta Description' }), * ], * }), * ], * access: { * read: true, * update: ({ roles }) => roles.includes('admin'), * }, * }); * ``` */ interface SingleConfig { /** * Unique identifier for the Single. * * Used as the database table name (with `single_` prefix), API endpoint, * and internal reference. Must be: * - Unique across all Singles AND Collections * - URL-friendly (lowercase, no spaces) * - Not a reserved name * * @example 'site-settings', 'header', 'footer', 'homepage' */ slug: string; /** * Field definitions for the Single. * * An array of field configurations that define the document structure. * Supports all 26 field types from the Collections system. * * @example * ```typescript * fields: [ * text({ name: 'siteName', required: true }), * upload({ name: 'logo', relationTo: 'media' }), * array({ * name: 'socialLinks', * fields: [ * text({ name: 'platform', required: true }), * text({ name: 'url', required: true }), * ], * }), * ] * ``` */ fields: FieldConfig[]; /** * @experimental Internal/private storage (D30). Stays in the merged schema * (accessible via services / raw db) but hidden from the admin nav (implies * `admin.hidden`). */ internal?: boolean; /** * Display label for the Admin UI. * If not provided, the label is auto-generated from the slug. * * @example * ```typescript * label: { singular: 'Site Settings' } * ``` */ label?: SingleLabel; /** * Enable the Draft / Published lifecycle for this Single. * * When `true`, Nextly injects a `status` system column on the data table * (NOT NULL, default `'draft'`) and the admin edit page shows separate * Save Draft / Publish buttons. Public callers querying with * `{ status: { equals: "published" } }` will only see published values; * drafts remain admin-only. * * Mirrors the Schema Builder's Advanced tab "Status (Draft / Published)" * toggle so code-first and Builder configurations converge on the same * underlying behaviour. * * @default false */ status?: boolean; /** * Enable content versioning (revision history) for this Single. * * Currently active: when enabled, every update records a restorable snapshot * of the assembled document in the global `nextly_versions` table, written * inside the same transaction as the write. Omitted = unversioned. * * Reserved (accepted but NOT yet enforced): the `drafts`, `autosave`, and * `maxPerDoc` retention settings on {@link VersionsConfig} are parsed and * persisted for forward compatibility, but are not wired up yet, so enabling * versioning is capture/history only regardless of those settings. The * deprecated `status: true` alias does not yet drive a version draft * lifecycle; use the separate `status` option for the draft/published column. * * @default undefined (unversioned) */ versions?: boolean | VersionsConfig; /** * Enable multilingual content for this Single. When `true`, translatable * fields store a value per configured locale (text-like fields localize by * default; override per field with the field's `localized` flag). Requires a * `localization` block in the app config. * * @default false */ localized?: boolean; /** * Webhook recording policy for this Single. When `false` (or * `{ record: false }`), writes to this Single record NO event to the webhook * outbox, so nothing is ever delivered to subscribed endpoints. The object * form leaves room for finer policy later without a breaking change. * * @default true (writes are recorded) */ webhooks?: boolean | { record?: boolean; }; /** * Admin panel configuration options. * Controls how the Single appears in the Admin UI. */ admin?: SingleAdminOptions; /** * Access control for read/update operations. * Defines who can view and modify the Single document. * * Each operation can be: * - A **function** receiving `AccessControlContext` (user, roles, permissions) → returns boolean * - A **boolean** for simple allow/deny * - **Omitted** to fall back to database role/permission checks * * @example * ```typescript * access: { * read: true, * update: ({ roles }) => roles.includes('admin'), * } * ``` */ access?: SingleAccessControl; /** * Lifecycle hooks. * Custom logic that runs during read/update operations. */ hooks?: SingleHooks; /** * Custom database table name. * * If not specified, the table name is generated from the slug * with a `single_` prefix (e.g., 'site-settings' -> 'single_site_settings'). * * @example 'single_site_config', 'global_settings' */ dbName?: string; /** * Description of the Single. * * Displayed in the Admin UI and used for documentation. * If not provided, falls back to `admin.description`. */ description?: string; /** * Cache-revalidation configuration. When a Next cache adapter is registered, * every write to this single busts its `nextly:single:{slug}` cache tag, so * tagged reads refresh on save. Use `tags` to bust extra shared tags on every * write, or `disable` to opt this single out of automatic revalidation. * * @default undefined (automatic revalidation on when a cache adapter exists) */ revalidate?: RevalidateConfig; /** * Custom metadata for plugins and extensions. * * Store arbitrary data that can be accessed by hooks, plugins, * or custom code. Not persisted to the database. * * @example * ```typescript * custom: { * cacheKey: 'global:site-settings', * } * ``` */ custom?: Record; /** * Whether to enable automatic input sanitization for this Single. * * When `true` (default), the global sanitization hook strips HTML tags * from plain-text fields (text, textarea, email) before database storage. * * Set to `false` to disable automatic HTML tag stripping for text fields. * Use with caution — only disable if this Single intentionally stores * HTML in text fields. * * @default true */ sanitize?: boolean; } /** * Dialect-Agnostic Type Definitions for Dynamic Singles * * These types define the structure for the `dynamic_singles` metadata table * and are used by all dialect-specific schemas (PostgreSQL, MySQL, SQLite). * * Singles are single-document entities * for storing site-wide configuration such as site settings, navigation menus, * footers, and homepage configurations. * * Key differences from Dynamic Collections: * - Only one document per Single (no list view) * - No create/delete operations (auto-created on first access) * - Simplified access rules (read/update only) * - No stored hooks (hooks are code-only for Singles) * - No pagination or list column configuration * * @module schemas/dynamic-singles/types * @since 1.0.0 */ /** * Source of the Single definition. * * - `code`: Defined in code via `defineSingle()` in a config file * - `ui`: Created through the Visual Single Builder in Admin UI * - `built-in`: System Singles provided by Nextly core (future use) * * @example * ```typescript * const source: SingleSource = 'code'; * ``` */ type SingleSource = "code" | "ui" | "built-in"; /** * Migration status for a Single's schema. * * - `synced`: Schema is in sync with the database (no pending changes) * - `pending`: Schema has changed but migration not yet created * - `generated`: Migration file has been created but not applied * - `applied`: Migration has been applied to the database (table verified to exist) * - `failed`: Migration was attempted but table creation failed * * @example * ```typescript * if (single.migrationStatus === 'pending') { * console.log('Run `nextly migrate:create` to generate migration'); * } * if (single.migrationStatus === 'failed') { * console.log('Table creation failed - check logs and retry'); * } * ``` */ type SingleMigrationStatus = "synced" | "pending" | "generated" | "applied" | "failed"; /** * Access control rules for a Single. * * Unlike Collections which have create/read/update/delete operations, * Singles only support read and update: * - **No create:** Document is auto-created on first access * - **No delete:** Singles always exist once accessed * * These rules are used for UI-created Singles. Code-first Singles * use the `access` property with functions instead. * * @example * ```typescript * // Public read, admin-only update * const accessRules: SingleAccessRules = { * read: { type: 'public' }, * update: { type: 'role-based', allowedRoles: ['admin'] }, * }; * * // Authenticated users can read and update * const authAccessRules: SingleAccessRules = { * read: { type: 'authenticated' }, * update: { type: 'authenticated' }, * }; * ``` */ interface SingleAccessRules { /** * Access rule for reading the Single document. * If not specified, defaults to public access. */ read?: StoredAccessRule; /** * Access rule for updating the Single document. * If not specified, defaults to public access. */ update?: StoredAccessRule; /** * Access rule for making the Single public (status → published). * If not specified, defaults to public access. */ publish?: StoredAccessRule; /** * Access rule for taking the Single down (status → out of published). * If not specified, defaults to public access. */ unpublish?: StoredAccessRule; } /** * Insert type for creating a new dynamic Single. * * Contains all required and optional fields for inserting a Single * into the `dynamic_singles` table. Fields with defaults (like * `schemaVersion`, `migrationStatus`) are optional on insert. * * @example * ```typescript * const newSingle: DynamicSingleInsert = { * slug: 'site-settings', * label: 'Site Settings', * tableName: 'single_site_settings', * fields: [ * { type: 'text', name: 'siteName', required: true }, * { type: 'text', name: 'tagline' }, * ], * source: 'code', * schemaHash: 'abc123...', * }; * ``` */ interface DynamicSingleInsert { /** * Unique slug identifier for the Single. * Used in URLs and API endpoints (e.g., "site-settings", "header"). * Must be unique across all Singles AND Collections. */ slug: string; /** * Display label for the Admin UI. * Unlike Collections, Singles only need a singular label. * * @example 'Site Settings', 'Header Navigation', 'Footer' */ label: string; /** * Database table name for this Single. * Must be unique across all tables. * Convention: prefix with `single_` (e.g., 'single_site_settings'). */ tableName: string; /** * Optional description of the Single's purpose. * Displayed in the Admin UI. */ description?: string; /** * Field configurations defining the Single's document structure. * Supports all 26 field types from the Collections system. */ fields: FieldConfig[]; /** * Admin UI configuration options. * Controls sidebar grouping, icon, visibility, etc. */ admin?: SingleAdminOptions; /** * Where the Single was defined. * - 'code': defineSingle() in a config file * - 'ui': Visual Single Builder * - 'built-in': System Singles from Nextly core */ source: SingleSource; /** * If true, the Single cannot be modified via the Admin UI. * Code-first Singles are locked by default. */ locked?: boolean; /** * Whether the Single carries a Draft/Published status column. * When true, a `status` column ('draft' | 'published', default 'draft') is * synthesized into the Single's table. Public callers see the published * version by default; admin callers see drafts. See the query-layer * `resolveStatusFilter` for enforcement. Default: false. */ status?: boolean; /** Single-level i18n master switch. Default: false. */ localized?: boolean; /** * Resolved content-versioning config (from `resolveVersionsConfig`), or null * when unversioned. Persisted on the `versions` column; the mutation service * reads it back to decide whether to capture a version snapshot on write. */ versions?: ResolvedVersionsConfig | null; /** * Cache-revalidation config (`{ tags?, disable? }`), or null when the single * sets none. Persisted on the `revalidate` column; the write path reads it * back to honor `disable` and merge extra `tags`. */ revalidate?: RevalidateConfig | null; /** * Webhook recording policy (`{ record: false }`), or null when the single * uses the default of recording. Persisted on the `webhooks` column so a * Builder-authored opt-out survives a restart; for `source: 'code'` singles * the code-first `webhooks` option stays the source of truth. */ webhooks?: StoredWebhookRecording | null; /** * Path to the config file (code-first Singles only). * Used for syncing and displaying source location. * * @example "src/singles/site-settings.ts" */ configPath?: string; /** * SHA-256 hash of the fields definition. * Used for change detection during sync operations. */ schemaHash: string; /** * Schema version number, incremented on each change. * Defaults to 1 for new Singles. */ schemaVersion?: number; /** * Current migration status. * Defaults to 'pending' for new Singles. */ migrationStatus?: SingleMigrationStatus; /** * Reference to the last applied migration ID. * Null for Singles that haven't been migrated yet. */ lastMigrationId?: string; /** * User ID who created the Single (optional). * Only set for UI-created Singles. */ createdBy?: string; /** * Access control rules for read/update operations. * * Defines who can read and update this Single. * If not specified, all operations default to public access. * * Note: Singles don't have create/delete access rules since * documents are auto-created and cannot be deleted. * * @example * ```typescript * accessRules: { * read: { type: 'public' }, * update: { type: 'role-based', allowedRoles: ['admin'] }, * } * ``` */ accessRules?: SingleAccessRules; } /** * Full record type for a dynamic Single. * * Extends `DynamicSingleInsert` with all required fields that are * set by the database (id, timestamps) or have default values. * * @example * ```typescript * const single: DynamicSingleRecord = { * id: 'uuid-123', * slug: 'site-settings', * label: 'Site Settings', * tableName: 'single_site_settings', * fields: [...], * source: 'code', * locked: true, * schemaHash: 'abc123...', * schemaVersion: 1, * migrationStatus: 'applied', * createdAt: new Date(), * updatedAt: new Date(), * }; * ``` */ interface DynamicSingleRecord extends DynamicSingleInsert { /** * Unique identifier (UUID or CUID). * Auto-generated by the database. */ id: string; /** * Schema version number (required, starts at 1). */ schemaVersion: number; /** * Current migration status (required). */ migrationStatus: SingleMigrationStatus; /** * Whether Single is locked from UI edits (required). * Code-first Singles are always locked. */ locked: boolean; /** * Whether Draft/Published status is enabled (required, defaults to false). */ status: boolean; /** Whether single-level i18n is enabled (required, defaults to false). */ localized: boolean; /** * When the Single was created. * Auto-set by the database. */ createdAt: Date; /** * When the Single was last updated. * Auto-updated on each modification. */ updatedAt: Date; } /** * Single Registry Service * * Unified registry for managing both code-first and UI-created Singles. * Central point for registering, updating, syncing, and inspecting * Single metadata (not data — data lives in `single_{slug}` tables). * * Extends {@link BaseRegistryService} for shared CRUD, migration * tracking, and utility patterns. Single-specific responsibilities: * * - Code-first sync with schema hash change detection * - Source locking (code-first Singles are read-only from the UI) * - Force-required delete (Singles are meant to persist) * - Permission seeding via PermissionSeedService * - Transaction-scoped registration for atomic multi-step flows * * Key differences from CollectionRegistryService and FieldGroupRegistryService: * - Singles use a singular `label` string (no plural form) * - Singles use `single_` table prefix instead of `dc_` or `comp_` * - Delete requires `force: true` (Singles should persist) * - Access rules only support `read` and `update` (no `create`/`delete`) * - Source includes `"built-in"` alongside `"code"` and `"ui"` * * @module domains/singles/services/single-registry-service * @since 1.0.0 */ /** * Options for updating a Single from the registry. */ interface UpdateSingleOptions { /** * Source making the update. * Used to enforce locking rules (code-first Singles can't be updated from UI). */ source?: SingleSource; } /** * Options for deleting a Single. */ interface DeleteSingleOptions { /** * Force deletion even for locked Singles. * Required because Singles should normally persist. * Use only for admin/CLI operations to clean up orphaned Singles. */ force?: boolean; } /** * Input for registering a code-first Single during sync. */ interface CodeFirstSingleConfig { /** Unique slug identifier */ slug: string; /** Display label */ label: string; /** Field configurations */ fields: DynamicSingleInsert["fields"]; /** Optional description */ description?: string; /** Optional table name (defaults to single_${slug}) */ tableName?: string; /** Whether the Single has the Draft/Published status feature enabled. */ status?: boolean; /** Whether single-level i18n is enabled (mirrors `status`). */ localized?: boolean; /** Resolved content-versioning config (or null when unversioned). */ versions?: DynamicSingleInsert["versions"]; /** Cache-revalidation config (or null when the single sets none). */ revalidate?: DynamicSingleInsert["revalidate"]; /** Webhook recording policy (or null when the single records, the default). */ webhooks?: DynamicSingleInsert["webhooks"]; /** Admin UI configuration */ admin?: DynamicSingleInsert["admin"]; /** Path to the config file */ configPath?: string; } /** * Result of syncing code-first Singles. */ interface SyncSingleResult { /** Slugs of newly created Singles */ created: string[]; /** Slugs of Singles that were updated (schema changed) */ updated: string[]; /** Slugs of Singles that were unchanged */ unchanged: string[]; /** Errors encountered during sync */ errors: Array<{ slug: string; error: string; }>; } /** * Options for listing Singles. */ interface ListSinglesOptions extends BaseListOptions { source?: SingleSource; migrationStatus?: SingleMigrationStatus; } /** * Result of listing Singles with pagination info. */ type ListSinglesResult = BaseListResult; /** * Single Registry Service * * Manages the `dynamic_singles` metadata table for both code-first * and UI-created Singles. Provides schema hash-based change detection * for code-first Single syncing. */ declare class SingleRegistryService extends BaseRegistryService { protected readonly registryTableName = "dynamic_singles"; protected readonly resourceType = "Single"; protected readonly tableNamePrefix = "single_"; /** Optional PermissionSeedService for auto-permission management. */ private permissionSeedService?; /** * Live code-first Single configs, kept for their field `defaultValue`s. A * `defaultValue` is a function, which is dropped when field metadata is * JSON-serialized to `dynamic_singles.fields`, so the default resolution on a * first-read auto-create reads defaults from here instead of the serialized * (function-less) registry row. Undefined for UI-created Singles. Set only * AFTER a successful metadata sync (boot and HMR reload) via * {@link setCodeFirstSingles}, never eagerly — otherwise new live fields could * pair with stale serialized metadata for a single whose sync failed. */ private codeFirstSingles?; constructor(adapter: DrizzleAdapter, logger: Logger); /** * Update the live code-first config snapshot after a metadata sync. Pass * `keepPriorFor` (the slugs whose sync FAILED) to retain their previous * snapshot entry instead of the new config: a failed single's serialized * metadata did not advance, so exposing its new fields would mis-encode a * default against the old type. A failed single with no prior entry is * dropped (it falls back to the serialized fields). */ setCodeFirstSingles(singles: SingleConfig[], options?: { keepPriorFor?: ReadonlySet; }): void; /** * Drop snapshot entries whose slug is not in `presentSlugs`, leaving the * surviving entries untouched. Used on a config reload to evict a removed * Single's live defaults BEFORE any later `setCodeFirstSingles` call — so a * reload that aborts (introspection failure, deferred schema, apply failure) * after a Single was removed cannot leave its stale function defaults runnable * against the removed-but-still-readable registry row. Remove-only: it never * updates a surviving entry, so it cannot pair new live fields with stale * serialized metadata the way an eager replace could. */ pruneCodeFirstSingles(presentSlugs: ReadonlySet): void; /** * The live code-first field definitions for a Single (with `defaultValue` * functions intact), or undefined for a UI-created Single. Callers resolving * declared defaults use these instead of the serialized `dynamic_singles.fields`. */ getCodeFirstFields(slug: string): SingleConfig["fields"] | undefined; protected getSearchColumns(): string[]; /** * Set the PermissionSeedService for auto-seeding permissions on single changes. * Called from DI registration after both services are constructed. */ setPermissionSeedService(service: PermissionSeedService): void; getSingleBySlug(slug: string): Promise; getSingle(slug: string): Promise; getAllSingles(options?: ListSinglesOptions): Promise; listSingles(options?: ListSinglesOptions): Promise; isLocked(slug: string): Promise; updateMigrationStatus(slug: string, status: SingleMigrationStatus, migrationId?: string): Promise; updateMigrationStatusWithVerification(slug: string, tableName: string): Promise<{ verified: boolean; status: SingleMigrationStatus; }>; getPendingMigrations(): Promise; /** * Register a new Single in the registry. * * @throws NextlyError(DUPLICATE) if a Single with the same slug already exists. * @throws NextlyError(DATABASE_ERROR) on insert failure. */ registerSingle(data: DynamicSingleInsert): Promise; /** * Register a Single within a transaction. */ registerSingleInTransaction(tx: TransactionContext, data: DynamicSingleInsert): Promise; /** * Publish a newly registered Single's recording decision into the live * policy. Shared by both registration paths so a Single created inside a * caller's transaction is not left recording until the next restart. * * Code-first Singles are skipped: their config is the source of truth and the * config publisher has already applied it. On the transactional path this runs * before the caller's transaction commits, which is safe in both directions — * a rolled-back Single has no rows to record, and the next boot republishes * from whatever the registry actually contains. */ private publishRegisteredRecording; /** * Update a Single's metadata. * * @throws NextlyError(NOT_FOUND) when no Single matches the slug. * @throws NextlyError(FORBIDDEN) when the Single is locked and the source isn't "code". */ updateSingle(slug: string, data: Partial, options?: UpdateSingleOptions): Promise; /** * Delete a Single from the registry. * * Singles represent persistent site-wide config, so deletion requires * `force: true`. Use only for admin/CLI operations to clean up orphans. */ deleteSingle(slug: string, options?: DeleteSingleOptions): Promise; /** * Sync code-first Singles with the registry. * * Compares schema hashes to detect changes and creates/updates * Singles as needed. Typically called during application startup. */ syncCodeFirstSingles(configs: CodeFirstSingleConfig[]): Promise; /** * Deserialize a raw DB row into a typed {@link DynamicSingleRecord}. * * Handles snake_case-to-camelCase normalization and JSON column parsing * so callers always receive the canonical record shape regardless of * which adapter returned it. */ protected deserializeRecord(record: DynamicSingleRecord | Record): DynamicSingleRecord; /** * Seed read/update permissions for a single and assign to super_admin. * Non-blocking — errors are logged but do not fail the parent operation. */ private seedPermissionsForSingle; /** * Build the common insert record shape for registerSingle and * registerSingleInTransaction. Extracted so both flows stay in sync. */ private buildInsertRecord; /** * Handle an error thrown during code-first sync. Disambiguates * duplicate-key errors (which are recoverable) from hard failures. */ private handleSyncError; } /** * Single Entry Service * * Thin facade that preserves the original SingleEntryService public API * while delegating to the split query and mutation services. This keeps * every existing caller (API routes, DI container, direct API, CLI) * working unchanged * * See: * - {@link SingleQueryService} — read operations (get, auto-create, expansion) * - {@link SingleMutationService} — write operations (update, hook mutation flow) * * @module domains/singles/services/single-entry-service * @since 1.0.0 */ /** * Single Entry Service facade. * * Constructs a {@link SingleQueryService} and {@link SingleMutationService} * from the provided dependencies and delegates every public method. The * constructor signature mirrors the pre-decomposition god file so DI * registration and tests do not need to change. */ declare class SingleEntryService extends BaseService { /** * Retention passes offered after a write, so a frequently-written single * trims what it fills without waiting for a scheduled drain. The shared * runner carries both — the webhook outbox and the audit trails — each on * its own window and its own gate, and decides which are configured. * * Absent only when NEITHER has anything to prune: an install with webhook * retention off and audit retention on still gets a runner. A construction * site forwarding one policy and not the other leaves that domain unpruned * rather than failing, so both belong here. */ private readonly retentionRunner?; /** * Kicks an immediate, bounded drain after a write (via Next `after()`) so a * single's outbox rows are delivered without waiting for the scheduled * trigger. Shared with the collection write path; absent when webhooks were * never registered. */ private readonly fastDrainScheduler?; /** * Resolves the cache revalidator that flushes a single write's revalidation * intent post-commit. Shared with the collection write path. A resolver (not * the instance) so it is read at flush time: this service is constructed * during boot, before a Next cache adapter registers, and an eager capture * would memoize the no-op default. Returns undefined when no adapter present. */ private readonly resolveCacheRevalidator?; private readonly queryService; private readonly mutationService; constructor(adapter: DrizzleAdapter, logger: Logger, singleRegistryService: SingleRegistryService, hookRegistry: HookRegistry, fieldGroupDataService?: FieldGroupDataService, rbacAccessControlService?: RBACAccessControlService, localization?: SanitizedLocalizationConfig, /** * Retention passes offered after a write, so a frequently-written single * trims what it fills without waiting for a scheduled drain. The shared * runner carries both — the webhook outbox and the audit trails — each on * its own window and its own gate, and decides which are configured. * * Absent only when NEITHER has anything to prune: an install with webhook * retention off and audit retention on still gets a runner. A construction * site forwarding one policy and not the other leaves that domain unpruned * rather than failing, so both belong here. */ retentionRunner?: RetentionRunner | undefined, /** * Kicks an immediate, bounded drain after a write (via Next `after()`) so a * single's outbox rows are delivered without waiting for the scheduled * trigger. Shared with the collection write path; absent when webhooks were * never registered. */ fastDrainScheduler?: WebhookFastDrainScheduler | undefined, /** * Resolves the cache revalidator that flushes a single write's revalidation * intent post-commit. Shared with the collection write path. A resolver (not * the instance) so it is read at flush time: this service is constructed * during boot, before a Next cache adapter registers, and an eager capture * would memoize the no-op default. Returns undefined when no adapter present. */ resolveCacheRevalidator?: (() => CacheRevalidator | undefined) | undefined); /** * Get a Single document by slug. * * If the document doesn't exist, it will be auto-created with default * field values. */ get(slug: string, options?: GetSingleOptions): Promise; /** * Update a Single document by slug. * * If the document doesn't exist, it will be auto-created first, * then updated with the provided data. */ update(slug: string, data: Record, options?: UpdateSingleOptions$1): Promise; /** * Flush a committed single write's cache-revalidation intent through the * registered revalidator (a no-op when no cache adapter is present). Awaited so * an async revalidator is not left detached; absorbs its own failure so * revalidation never turns a committed write into an error. */ private flushRevalidation; /** Retention batches to attempt per write, matching the collection path. */ private static readonly WRITE_PATH_PRUNE_BATCHES; } /** * Schema changes for a Single, owned in one place with the registry write they belong to. * * ## Why this exists * * A Single's table used to be created by the request handler: the handler generated the DDL, ran * it, and then wrote the registry row. That split is why a lock cannot cover the pair — a lock * taken inside the registry service is acquired after the tables have already changed. Collections * already avoid this by owning both halves in one method; this is the same shape for Singles. * * ## What it guarantees, and what it does not * * **NOT atomic, and not yet recoverable.** MySQL commits DDL implicitly, so a table change and a * row write cannot be made atomic there by any ordering or any transaction. The migration engine * reached the same conclusion and says so in `field-groups/migration/steps.ts` — "sequenced with * repair rather than atomic, and every half idempotent to make that repair possible". Promising * atomicity would be a promise that silently does not hold on one of the three supported databases. * * ## The write order, and what it costs * * The DDL runs first and the registry row is written last, carrying the outcome the apply reached. * Two consequences follow, and both are real: * * - A crash between the DDL and the row leaves a table nothing has any record of, findable only by * guessing at table names. * - A DDL that FAILS still writes its row, recording `failed`. That row owns the slug, and the * create path refuses a slug that is already owned, so a failed create cannot be retried through * the same path until the row is removed. * * Writing the intent first would trade the first cost for a worse one. A row persisted before the * table is touched owns the slug from that moment, and nothing here can yet finish or discard an * interrupted attempt, so a create killed mid-flight would block every retry rather than leaving a * table that at least harms nobody. The two halves have to arrive together: the ordering changes * when a recovery path exists to release what an interrupted attempt claimed. * * `SingleRegistryService.getPendingMigrations()` is the query such a path would use. It has no * callers, because nothing on this path ever leaves a row in `pending` except an app with no * adapter registered at all. * * 🔴 Everything that can REJECT a create runs before `createSingle` is called, so a rejected * request neither creates a table nor writes a row: field validation, the reserved-slug check, the * table-name conflict check and the global resource slug guard all belong to the caller. */ /** * 🔴 The schema generators, the runtime-schema builder and the companion reconcile are loaded on * demand, NOT at the top of this file. * * This service is registered in the DI container, and the registration module is imported during * boot by anything that touches the container. A static import here would pull the whole schema and * i18n machinery into that graph for every consumer, including every process that never creates a * Single. `di/register.ts` avoids exactly this with 37 `await import()` calls covering these same * three modules, and a static import from a registration module quietly undoes that work — measured * at +41% on the package's own test suite, enough to push its slowest files past their timeout. * * The cost of loading them here is paid once, on a path that is already writing DDL to a database. */ /** * The registry row to create, minus the one field this service owns. * * Deliberately the registry's own insert type rather than a hand-listed subset: a bespoke input * shape silently drops whatever it forgets, and the fields most easily forgotten here (version * retention, revalidation, webhook recording) are the ones whose absence is invisible until a * user notices a switch reading as off. */ type CreateSingleInput = Omit; /** What the caller gets back: the row, and how far the schema change actually got. */ interface CreateSingleResult { record: DynamicSingleRecord; migrationStatus: SingleMigrationStatus; } /** * A schema change to an existing Single, with everything the caller has already decided. * * The caller owns every rejection: the locked-single check, the field-payload validation, the * retention-without-toggle rejection and the localization-config gate all run before this is * built. What arrives here is a change that is allowed to proceed. * * The two flag pairs are passed rather than derived because only the caller can tell them apart. * `hasStatus`/`isLocalized` are what the single is being saved AS, `wasStatus`/`wasLocalized` what * it currently IS, and an undefined toggle in the request body means "leave alone" — which reads * as the previous value, not as `false`. */ interface UpdateSingleSchemaInput { slug: string; existing: DynamicSingleRecord; /** * The registry columns to write, minus `migrationStatus`, which this service owns. * * Passed through rather than rebuilt: the caller has already normalised the version-retention, * revalidation and webhook toggles into the resolved configs the runtime readers test, and * re-deriving them here would be a second implementation of that normalisation. */ updateData: Record; /** The new field list, or undefined when the save changes only flags. */ fields?: FieldDefinition[]; isLocalized: boolean; wasLocalized: boolean; /** * Whether the request SET the Internationalization toggle, as opposed to leaving it alone. * * The mirror of {@link UpdateSingleSchemaInput.statusRequested}, and required for the same * reason: `isLocalized` falls back to the value the CALLER read, so once this service re-reads * the record it cannot tell "the user asked for localized: false" from "the user said nothing and * the caller filled in what it saw". Only the first should survive a refresh. */ localizedRequested: boolean; hasStatus: boolean; wasStatus: boolean; /** * Whether the request SET the Draft/Published toggle, as opposed to leaving it alone. * * Not derivable from `hasStatus !== wasStatus`: saving the toggle at the value it already holds * is a request that reaches the companion, because provisioning is idempotent and a single whose * companion `_status` never got created is repaired by exactly that save. Collapsing the two * would turn the repair into a no-op. */ statusRequested: boolean; } /** The updated row, and the status the caller reports back to the user. */ interface UpdateSingleSchemaResult { record: DynamicSingleRecord; migrationStatus: SingleMigrationStatus; } declare class SingleMetadataService { private readonly registry; private readonly logger; /** * Optional on purpose, and it changes what this service does rather than whether it works. * * With no adapter registered the statements are generated and never run — the behaviour the * request handler had before this service existed. Demanding a connection here would turn a * configuration this product supports into a crash. */ private readonly adapter?; constructor(registry: SingleRegistryService, logger: Logger, /** * Optional on purpose, and it changes what this service does rather than whether it works. * * With no adapter registered the statements are generated and never run — the behaviour the * request handler had before this service existed. Demanding a connection here would turn a * configuration this product supports into a crash. */ adapter?: DrizzleAdapter | undefined); /** * The dialect the DDL is generated for. * * Read from the adapter that will RUN the statements, never from the schema service's own * default. `DB_DIALECT` is optional and falls back to `postgresql`, so an app configured with * only a MySQL or SQLite URL would otherwise have its table created as PostgreSQL. */ private get dialect(); /** * Create a Single's table and its registry row. * * The caller has already validated the input and established that no other Single owns this * table name. Rejecting after this point would leave a `pending` row behind. */ createSingle(input: CreateSingleInput): Promise; private createSingleExcluded; /** * Remove a Single: its storage first, then its registry row. * * The order is the opposite of the create's and for the same reason. A create writes the row last * so a failure cannot leave a row describing storage that was never made; a delete drops the * storage first so a failure cannot leave storage that no row describes. Both put the registry * write on the side where an interruption is visible rather than invisible. * * Failures propagate here rather than being recorded as a status. A single that cannot be fully * removed stays intact and retryable, which is a better state than one whose row is gone while its * tables survive: the row is what makes the tables findable. */ deleteSingle(slug: string, tableName: string | undefined): Promise; private deleteSingleExcluded; /** * Apply a schema change to an existing Single and write the registry row that describes it. * * The same three phases as `createSingle`, and for the same reason: a lock has to cover the * table change and the row write together, and it can only do that where both halves live. * * 🔴 The phase boundary is what decides whether a failure is raised or recorded, and it replaces * a `migrationBegan` flag the request handler carried. Everything that can reject — reading the * live table, asking the generator for statements — happens in the PLAN, where the schema is * still exactly as it was and the caller's field list has not been saved. Once APPLY starts, a * statement may already have run, so a failure is a partly-applied migration that must be * recorded rather than a request that never began. */ updateSingleSchema(input: UpdateSingleSchemaInput): Promise; private updateSingleSchemaExcluded; /** * Work out what the schema change has to do, reading the live table but changing nothing. * * Returns null when the save asks nothing of the schema: no field change, no Internationalization * transition, and no Draft/Published save on a single that has a companion to keep in step. * * Allowed to throw, and that is the point. The generator is a validator as well as a renderer — * it refuses a required column with no value for the rows already there, or a referenced column * SQLite cannot detach — and refusing here, before the apply, is what leaves the table untouched * and the caller's field list unsaved. */ /** * Read the Single again inside the exclusion, and re-check what the caller checked outside it. * * Both refusals are re-checked rather than trusted: a delete that lands while this request waited * leaves nothing to update, and a Single that became `locked` in the same window is code-first * now, so applying a UI edit to it would write a row the config is about to contradict. */ private refreshForUpdate; private planUpdate; /** * The two field lists the ALTER diff compares, normalised to describe the same table. * * Two adjustments, and both exist because the stored field list and the physical table are not * the same thing: * * - The physical table always carries `title`, `slug` and `updated_at`, which the generators add * and the stored definitions may not mention. Without them the diff plans an ADD COLUMN for * columns that already exist. They are matched by the COLUMN a field becomes rather than by * its name: a field named `Title` already owns the `title` column, and prepending the system * one beside it would hand the diff two fields for one column. * - i18n: translatable columns live on the companion whenever the single is localized in either * state, so they are dropped from both sides — the main-table diff must never ADD or DROP * them. `reconcileSingleCompanion` owns that side. */ private normalizeFieldsForAlter; /** The live-table facts the ALTER generator needs, read in one round trip. */ private readLiveTableFacts; /** * Run the plan, reporting how far it got. * * Returns undefined when the plan owns no status — a flag-only save with no adapter registered * asked nothing of the main table's schema, so overwriting the previous verdict with one about a * migration that was never requested would be a claim this apply cannot make. * * 🔴 Never throws once it has begun. The PHASE decides that, not the error type: from here on a * statement may already have run, and raising would skip the row write and leave the registry * describing storage that no longer matches it. Refusals are the plan's job, which is where * raising is free because nothing has been touched yet. */ private applyUpdateDdl; /** * Forget the main table's registered shape, so the next read rebuilds it from the database. * * Best-effort in the same way the registration is: a resolver that cannot retract leaves the * previous entry in place, which is where this path was before the method existed. */ private retractRuntimeSchema; /** * Rebind the main table to the running server so the next read sees its new column shape. * * Best-effort, like the create path's: the registry is rebuilt from the database on the next * boot, so a failure costs a restart rather than the migration. */ private registerUpdatedRuntimeSchema; /** * Render the DDL and work out what the table must look like once it has run. * * Separated from the apply because the two have opposite contracts: this one is allowed to * REJECT the request and must do so before anything is persisted, while the apply must never * throw so a failure is still recorded against a row. */ /** * Re-run the create's ownership preconditions, inside the exclusion. * * Asks the same two questions the caller asked and re-uses the same shared guard for the second, * rather than restating either: a table another Single already owns, and a slug some other * resource kind has taken. */ private assertCreateStillPossible; private planCreate; /** * Run the create DDL, reporting how far it got. * * Never throws: a schema change that fails is recorded rather than raised, so the caller still * has a row describing what was attempted. That is the same choice the request handler made * before this service existed, and it is what makes the state repairable instead of lost. */ private applyCreateDdl; /** * Bind the new table to the running server so the next read resolves it. * * Best-effort by design: the registry is rebuilt from the database on the next boot, so a * failure here costs a restart rather than the table. Taken from the adapter that ran the DDL * rather than from the container, because that adapter is the one whose reads have to resolve * the name and a caller may hold one the container has never seen. */ private registerRuntimeSchema; } type SupportedDialect$2 = "postgresql" | "mysql" | "sqlite"; /** * The narrow database surface the versions domain needs. Both the adapter * (non-transactional) and the transaction context passed to * `adapter.transaction(cb)` structurally satisfy it, so a repository built on * `VersionsDbApi` works for both reads (via the adapter) and in-transaction * capture (via the tx context) without depending on Drizzle internals. * * @module domains/versions/db-api */ /** A single filter condition (subset of the adapter WhereClause). */ interface VersionsWhereCondition { column: string; op: "=" | "!=" | "<" | "IN" | "IS NULL" | "IS NOT NULL"; value?: SqlParam | SqlParam[]; } /** * A where clause: a conjunction (`and`) of conditions or nested clauses, with an * optional disjunction (`or`) group. Still a strict subset of the adapter's * WhereClause, so both the adapter and the transaction context satisfy the port. * The `or` group exists for the locale filter alone (locale X OR shared/null). */ interface VersionsWhere { and?: (VersionsWhereCondition | VersionsWhere)[]; or?: (VersionsWhereCondition | VersionsWhere)[]; } /** Select options subset the versions repository uses. */ interface VersionsSelectOptions { columns?: string[]; where?: VersionsWhere; orderBy?: { column: string; direction?: "asc" | "desc"; }[]; limit?: number; } /** The database methods the versions repository depends on. */ interface VersionsDbApi { /** * Which engine this handle talks to, where the handle knows. * * Optional so the transaction context, which does not carry it, still * satisfies this port. Only the autosave upsert reads it, and only to * classify a driver error: a constraint code means different things per * engine, so a handle that cannot say which engine it is must not have its * errors guessed at. */ readonly dialect?: SupportedDialect$2; insert(table: string, data: Record, options?: { returning?: string[] | "*"; }): Promise; select(table: string, options?: VersionsSelectOptions): Promise; /** * Delete rows matching `where`, returning the number removed. No options * parameter: retention needs none, and omitting it keeps the port satisfied * by both the adapter and the transaction context, whose wider `where` and * extra optional arguments remain assignable to this narrower shape. */ delete(table: string, where: VersionsWhere): Promise; /** * Update rows matching `where`. * * Narrow for the same reason as `delete`: the only thing that edits a stored * version is its label, and a snapshot is never rewritten. Keeping the port * to what is actually used means both the adapter and the transaction * context satisfy it without adapting, and makes any future widening a * deliberate act rather than an inherited capability. */ update(table: string, data: Record, where: VersionsWhere): Promise; } /** * Repository for `nextly_versions`, the global content-version store. * * Built on the adapter DB API (VersionsDbApi) so the same class can be * constructed with either the adapter or a transaction context. All reads go * through the Drizzle-backed adapter select (the transaction context binds its * executor to the same path), so `createdAt`/`snapshot` are decoded (JSON * parsed, timestamps become `Date`) whichever handle is used. Column names are * the Drizzle property names (camelCase); the adapter maps them to snake_case. * * @module domains/versions/versions-repository */ /** Identifies the document a version belongs to. */ interface VersionRef { scopeKind: VersionScopeKind; scopeSlug: string; entryId: string; } /** A full version row (camelCase, as the adapter returns it). */ interface VersionRow { id: string; scopeKind: VersionScopeKind; scopeSlug: string; entryId: string; versionNo: number | null; status: VersionStatus; isAutosave: boolean; snapshot: unknown; label: string | null; locale: string | null; sourceVersionNo: number | null; createdBy: string | null; createdAt: Date; updatedAt: Date; } /** Metadata view of a version row (everything except the snapshot). */ type VersionMeta = Omit; /** * What an autosave write reports back. * * Taken from the values the write itself used rather than re-read afterwards: * autosave runs while somebody is typing, so a confirmation SELECT on every * keystroke-batch would be real cost for data the write already holds. */ interface AutosaveWriteResult { updatedAt: Date; locale: string | null; } /** * Public surface for content version history. * * Wraps the repository so callers (HTTP routes, the admin, and plugins via * `ctx.services.versions`) never touch `nextly_versions` directly. Listing is * metadata-only by construction: snapshots are large and a history list never * needs them. * * Reads, plus the one thing about a stored version that is editable: its label. * A snapshot itself is never rewritten — history is append-only, which is what * makes a restore recoverable. * * @experimental The shape may change while versioning is in alpha. * * @module domains/versions/versions-service */ /** Options for a history listing. */ interface VersionListOptions { /** Page size. */ limit?: number; /** Return versions strictly older than this versionNo (keyset pagination). */ cursor?: number; /** Include rolling autosave rows. Defaults to false (durable versions only). */ includeAutosave?: boolean; /** Scope the listing to one locale's versions. Absent lists every locale. */ locale?: string; } declare class VersionsService { private readonly repo; constructor(db: VersionsDbApi); /** Version metadata for one document, newest-first. Never loads snapshots. */ list(ref: VersionRef, opts?: VersionListOptions): Promise; /** * Name a version, or clear its name with `null`. * * The only mutation on a stored version. `get` runs first so an unknown * version answers not-found rather than silently updating nothing — the * repository's `UPDATE ... WHERE` matches no rows in that case and would * otherwise report success. */ setLabel(ref: VersionRef, versionNo: number, label: string | null): Promise; /** One full version, including its snapshot. */ get(ref: VersionRef, versionNo: number): Promise; /** * Discard a document's pending working draft in one locale, returning the * number of rows removed (0 when none exists). * * The working draft is the status-less sidecar the draft/published split * writes for edits to a published document. Removing it reverts the editor to * the live published row; history is untouched, which is why this discards * unpublished edits rather than rewriting a version. */ deleteWorkingDraft(ref: VersionRef, locale: string | null): Promise; /** * Record one author's rolling recovery point for a document. * * Outside any transaction, unlike durable capture. A durable version is part * of the write that produced it and must land or roll back with it; a * recovery point describes work that has not been written at all, so there is * no surrounding write for it to join. That also keeps a slow snapshot from * holding a transaction open while somebody types. * * Rewrites the one row this author holds for this document rather than adding * to history, so an editing session costs a single row and durable history is * untouched. */ autosave(input: { ref: VersionRef; status: VersionStatus; snapshot: unknown; locale?: string | null; createdBy?: string | null; }): Promise; /** * One author's current recovery point, or undefined when they have none. * * Scoped to the caller rather than the document: an autosave is unpublished, * unvalidated work in progress, so one author's must never be offered to * another. This is the only way a stored autosave can be read back -- * history listings and version reads both exclude them by construction, * since a recovery point carries no version number to be addressed by. */ getAutosave(ref: VersionRef, createdBy: string | null): Promise; } /** * Webhook domain — delivery read service. * * Backs the admin delivery log: lists an endpoint's delivery attempts and reads * one delivery's attempt history. Read-only; the drain owns every write to * `nextly_webhook_deliveries`. Each delivery row carries only its `event_id`, so * the event type and resource come from a join to `nextly_events`. * * Nothing here is credential-bearing: a delivery row records retry state, the * last response status/latency/error, a truncated response snippet, and a * per-attempt log of `{ at, outcome, statusCode, latencyMs, error }`. The * request headers actually sent (which may include a receiver credential) are * never persisted, so this read surface cannot leak one. * * @module domains/webhooks/services/webhook-delivery-query-service */ /** Lifecycle state of a delivery, mirroring the drain's status vocabulary. */ type WebhookDeliveryStatus = "pending" | "processing" | "delivered" | "retrying" | "failed"; /** The resource an event was about, surfaced from the joined event row. */ interface WebhookDeliveryResource { kind: string; collection: string | null; id: string | null; /** * Which translation changed, for a localized resource. Two writes to the * same document in different locales share kind/collection/id and are only * told apart by this, so the log would otherwise collapse them. */ locale: string | null; } /** One recorded attempt, as stored on the delivery row's `attempts` log. */ interface WebhookDeliveryAttempt { at: string; outcome: string; statusCode?: number; latencyMs?: number; error?: string; } /** A delivery as the log list shows it (joined to its event). */ interface WebhookDeliverySummary { id: string; webhookId: string; eventId: string; eventType: string; resource: WebhookDeliveryResource; status: WebhookDeliveryStatus; attemptCount: number; lastStatusCode: number | null; lastLatencyMs: number | null; lastError: string | null; /** When the next retry is due, or null when the delivery is terminal. */ nextAttemptAt: Date | null; /** When the underlying event was recorded (event row's created_at). */ eventCreatedAt: Date; createdAt: Date; updatedAt: Date; } /** A single delivery with its full attempt history and response snippet. */ interface WebhookDeliveryDetail extends WebhookDeliverySummary { attempts: WebhookDeliveryAttempt[]; /** Truncated response body from the last attempt, or null. */ lastResponseSnippet: string | null; } /** Filters and paging for a delivery list. `page`/`limit` are 1-based/bounded. */ interface ListDeliveriesOptions { page: number; limit: number; status?: WebhookDeliveryStatus; eventType?: string; } declare class WebhookDeliveryQueryService extends BaseService { private readonly deliveries; private readonly events; constructor(adapter: ConstructorParameters[0], logger: ConstructorParameters[1]); /** * Turn a driver error into the canonical envelope so a raw driver exception * never escapes `packages/nextly`. */ private query; /** The columns pulled from the delivery+event join, shared by list and get. */ private get selection(); /** WHERE fragments shared by the list page query and its count. */ private listConditions; /** * A page of an endpoint's deliveries, newest first, plus the total for paging. * * Scoped by `webhookId` alone: deliveries outlive their endpoint (a retired * endpoint keeps its history), so this deliberately does not require the * endpoint to still be live. */ listDeliveries(webhookId: string, opts: ListDeliveriesOptions): Promise<{ items: WebhookDeliverySummary[]; total: number; }>; /** * One delivery with its attempt history, or null when no delivery with this * id belongs to this endpoint. */ getDelivery(webhookId: string, deliveryId: string): Promise; /** Map a joined row to the list summary, normalizing timestamps to ISO-UTC. */ private toSummary; /** Map a joined row to the detail shape, including the attempt log. */ private toDetail; /** * Coerce the stored attempt log to the public shape. A malformed or missing * value (a legacy or manual write) reads as an empty history rather than * throwing, so one bad row cannot break the whole log view. */ private normalizeAttempts; } /** The result of a connectivity probe, surfaced to the operator. */ interface WebhookTestResult { /** True only for a 2xx response (per {@link classifyResponse}). */ delivered: boolean; /** The HTTP status, when a response was received (absent if the request threw). */ statusCode?: number; latencyMs: number; /** A short reason when not delivered: `http ` or the transport error. */ error?: string; /** A truncated copy of the receiver's response body, when readable. */ responseSnippet?: string; } /** * Webhook domain — endpoint management. * * The delivery engine, fan-out, signing and retention were all built before * anything could create an endpoint for them to act on: the only rows that ever * reached `nextly_webhooks` were test fixtures. This is the surface that makes * the rest of the domain reachable. * * Two behaviours here are security-relevant rather than cosmetic. * * A URL is resolved and checked before it is stored, not only before it is * called. Delivery already refuses private, loopback and cloud-metadata * addresses through `safeFetch`, and that check is the one that cannot be * fooled by a hostname re-pointed after registration — but it fires long after * the person who typed the URL has gone. Checking at registration turns a * silent, repeated delivery failure into an immediate, correctable error. * * Secrets are stored encrypted and can be read back by a caller the route * authorises. That follows the webhook-first providers rather than the * write-only model: a secret that can never be re-read forces a full rotation * every time an operator loses their copy, and rotation is the more dangerous * operation. The column is list-shaped for the same reason — Standard Webhooks * rotation signs with every active secret at once. * * @module domains/webhooks/services/webhook-endpoint-service */ /** * A signing secret described without revealing it: the display prefix and its * lifecycle. `isPrimary` marks the secret new deliveries are prefixed by; * `expiresAt` is when an overlapping (rotated-away) secret stops signing, or * null for the primary. Safe to return on an ordinary read — it carries no key * material, only what the admin needs to show a rotation's state. */ interface WebhookSecretInfo { prefix: string; isPrimary: boolean; createdAt: Date; expiresAt: Date | null; } /** * An endpoint as any caller after creation sees it. * * Carries `secretPrefix` and the `secrets` lifecycle summary but never a secret * or its ciphertext: reading a secret is a separate, separately-authorisable * act, so it must not ride along on an ordinary list or fetch. */ interface WebhookEndpointSummary { id: string; name: string; url: string; enabled: boolean; eventTypes: WebhookEventSubscription[]; headers: Record | null; /** Prefix of the current primary signing secret. */ secretPrefix: string; /** Every active signing secret's non-sensitive lifecycle, primary first. */ secrets: WebhookSecretInfo[]; createdBy: string | null; createdAt: Date; updatedAt: Date; } /** What creating an endpoint returns: the endpoint, plus its secret once. */ interface CreatedWebhookEndpoint { endpoint: WebhookEndpointSummary; secret: string; } interface CreateWebhookEndpointInput { name: string; url: string; eventTypes: WebhookEventSubscription[]; enabled?: boolean; headers?: Record | null; } interface UpdateWebhookEndpointInput { name?: string; url?: string; eventTypes?: WebhookEventSubscription[]; enabled?: boolean; headers?: Record | null; } interface RotateWebhookSecretInput { /** * How long the secret being rotated away stays valid, in seconds. 0 retires * it immediately; the default (when omitted) is the standard overlap window. */ overlapSeconds?: number; } declare class WebhookEndpointService extends BaseService { /** * Dropped on every mutation so a change takes effect without a restart. * Optional because the registry is constructed per drain today; once it * becomes a shared instance this is what keeps it honest. Without it a * disabled endpoint would keep receiving deliveries from a cached list, * silently and for as long as the process lives. */ private readonly registry?; private readonly table; /** Needed so disabling an endpoint can end the deliveries still queued for it. */ private readonly deliveries; constructor(adapter: ConstructorParameters[0], logger: ConstructorParameters[1], /** * Dropped on every mutation so a change takes effect without a restart. * Optional because the registry is constructed per drain today; once it * becomes a shared instance this is what keeps it honest. Without it a * disabled endpoint would keep receiving deliveries from a cached list, * silently and for as long as the process lives. */ registry?: Pick | undefined); /** * Drop the shared endpoint cache and re-derive the recording gate's presence * flag, after an endpoint mutation has committed. Runs on a pooled connection * outside any content transaction, so the recording choke point can keep * reading presence synchronously; awaited so a same-process change takes effect * before the mutation call returns. */ private refreshRecordingGate; /** * Run a database call, turning a driver error into the canonical envelope. * * Every statement here can fail for reasons the caller should see as a typed * error rather than a driver exception: `created_by` referencing a user that * has since been removed, a constraint violation, a lost connection. Without * this the raw driver `Error` escapes `packages/nextly`, where nothing above * knows how to render it. */ private query; /** * Reject a URL delivery could never safely call. * * Reuses the same validator the transport uses, so registration and delivery * cannot disagree about what is acceptable. The failure is translated to a * field-level validation error: this is a correctable mistake in submitted * input, not an internal fault, and it should read that way to whoever typed * it. */ private assertDeliverableUrl; /** * The row's signing secrets that are still live at `now`, primary first, * tolerating both the current entry form and the legacy bare-string form. * Shared by the summary (metadata only) and the reveal/test paths (which * decrypt), so expired overlap secrets are never surfaced or signed with. */ private liveSecretEntries; /** Narrow a stored row to what a caller may see. */ private toSummary; /** * Register an endpoint and return its signing secret once. * * The secret is generated here rather than accepted from the caller so it is * always the length and shape the signing path and a receiver's Standard * Webhooks library expect, and so it is never shared between endpoints. */ createEndpoint(input: CreateWebhookEndpointInput, createdBy: string | null): Promise; /** Every registered endpoint, newest first. */ listEndpoints(): Promise; /** * One endpoint, or null when there is no live endpoint with this id. * * A retired (soft-deleted) endpoint reads as null, the same as one that never * existed: it is kept only for its delivery history and is not part of the * manageable set. Callers cannot, and should not, tell the two apart. Every * read here filters `deleted_at IS NULL` for that reason, and `updateEndpoint` * relies on it to refuse edits to a retired row. */ getEndpoint(id: string): Promise; /** * Change an endpoint. Only the named fields move. * * A URL is re-validated on the way in, because an update is exactly how an * endpoint that passed at registration would be re-pointed somewhere it * should not reach. */ updateEndpoint(id: string, patch: UpdateWebhookEndpointInput): Promise; /** * End the deliveries still outstanding for an endpoint that was just disabled * or retired. * * Delivery refuses a disabled endpoint when it attempts one, which covers a * drain running during the window. It does not cover the window itself: with * no drain between disabling and re-enabling, those rows are still due and * would go out in a burst afterwards, which is the opposite of what disabling * promised. * * They are ended rather than held for the same reason delivery ends them. * Sending events an operator switched off, hours late, is worse than not * sending them, and replaying is a separate deliberate act. * * The `executor` is either the shared connection or a transaction, so a delete * can end the deliveries in the same transaction that retires the endpoint. * The `reason` is recorded on each row so the retained history says why they * stopped — "disabled" or "deleted" — rather than always "disabled". */ private cancelQueuedDeliveries; /** * Stop delivering without discarding the endpoint. * * Kept distinct from deletion because they are different intentions and only * one is reversible. An endpoint id tends to end up in someone's * infrastructure, so removing the row is the operation that cannot be undone. */ setEnabled(id: string, enabled: boolean): Promise; /** * Retire an endpoint while keeping its delivery history. * * The row is soft-deleted rather than removed: it disappears from every read * and stops receiving deliveries, but stays in the table so the delivery * ledger keeps a real endpoint on the other end of its foreign key. "What did * we send to that integration, and did it arrive?" is answerable after the * endpoint is gone, which is exactly when it tends to be asked. * * Disabling remains the way to pause an endpoint you intend to bring back; * this is for one you are finished with but whose record still matters. A row * once retired is not resurrected — a later registration is a new endpoint. */ deleteEndpoint(id: string): Promise; /** * Recover the active signing secrets. * * Separate from every other read so the route can require a stronger * permission for it: the secret is what proves a request came from this * install, and it should not arrive incidentally in a list response. * * Returns every active secret because rotation keeps more than one alive at * a time, and a caller reconciling their configuration needs to see them all. * * A retired endpoint is not found here, like every other read: its secrets are * also cleared on delete, so there would be nothing to return in any case. */ revealSecrets(id: string): Promise; /** * Validate a requested overlap window, defaulting when omitted. Enforced in * the service (not only the route) so a Direct-API caller cannot store an * out-of-range window that would keep an old key alive indefinitely. */ private resolveOverlapSeconds; /** * Read the endpoint's secrets under a `FOR UPDATE` row lock, let `mutate` * compute the next entry list, and write it back in the same transaction. * * The lock serializes concurrent secret writes: a second rotation blocks until * the first commits, then reads the updated row, so it can never overwrite the * first's freshly-issued primary from a stale snapshot. The retired-row check * runs inside the lock as well, so a rotation or expiry cannot write a secret * back onto an endpoint a concurrent `deleteEndpoint` just soft-deleted and * cleared. `secret_prefix` follows the new primary (the first entry). A missing * or retired row throws not-found. */ private withLockedSecrets; /** * Rotate the signing secret, keeping the previous one valid for an overlap * window so a receiver can switch over without dropping a delivery. * * A fresh secret becomes the primary that new deliveries are prefixed by. The * previous primary is stamped `expiresAt = now + overlapSeconds` and stays * live (and signed with, via the Standard Webhooks multi-signature header) * until then; `overlapSeconds = 0` retires it at once. At most one overlapping * secret is kept — rotating again while one is still overlapping retires the * older one — so a delivery never carries more than two signatures, and * already-expired entries are pruned. The read-modify-write runs under a row * lock, so concurrent rotations serialize rather than lose a secret. The new * secret is returned once. */ rotateSecret(id: string, input?: RotateWebhookSecretInput): Promise; /** * Retire every overlapping secret immediately, leaving only the primary. The * deliberate way to cut a rotation's overlap short once the receiver has * switched. Runs under the same row lock, so it cannot race a rotation or a * delete. A no-op (beyond a timestamp touch) when there is nothing to expire. */ expireOldSecrets(id: string): Promise; /** * Send a signed synthetic `webhook.ping` to the endpoint and report whether it * was reachable and accepted. A pure connectivity probe: it reads the endpoint * RAW (real headers + secrets, unlike the read-redacted summary) and posts * out-of-band, writing nothing to the outbox or the delivery queue. Works on a * disabled endpoint too, so an operator can verify a receiver before enabling * it. `transport` is injectable so tests drive outcomes without real network. */ testEndpoint(id: string, options?: { transport?: DeliverTransport; pingId?: string; now?: () => Date; }): Promise; /** * Re-arm a past delivery for another attempt. Scoped by `(webhookId, * deliveryId)` so a delivery can only be re-sent through the endpoint that * owns it. * * The unique `(webhook_id, event_id)` index forbids a second delivery row for * the same event, so this UPDATES the existing row back to a due state rather * than inserting: `pending`, `next_attempt_at = now`, lock cleared, and the * attempt budget reset — while the capped `attempts[]` history is left intact * so the prior failures stay visible. The delivery id (the Standard-Webhooks * `webhook-id`) is reused, so a receiver that already processed it dedupes. * The caller triggers the drain; the row is now claimable. * * Guards, resolved in this order: 404 if the delivery is unknown or belongs to * another endpoint (a mistyped or never-created `webhookId` yields no scoped * row and so is a not-found, never mistaken for a deleted endpoint); 409 if the * delivery is still in flight (a drain worker holds an unexpired lease); 409 if * the endpoint has been deleted or is disabled (delivering to it would fail). * * All of it runs in one transaction under a `FOR UPDATE` lock on the delivery * row (a no-op on SQLite, whose transactions already serialize writers). The * lock is the write's own lock, so a drain worker that would claim the delivery * blocks until this commits, and a worker already holding an unexpired lease is * seen and refused rather than revoked. Reading the row before writing is also * how the outcome is known, so success is reported only when the row was armed. * * The endpoint's state is read inside the same transaction rather than through * `getEndpoint` beforehand: `getEndpoint` cannot tell a soft-deleted endpoint * from one that never existed (both read back as null), which would report a * bogus id as a deleted-endpoint conflict instead of a not-found. Because a * delivery row outlives its endpoint's soft-delete (the tombstone only cancels * queued rows), confirming the delivery first and then inspecting the endpoint * row's `deleted_at`/`enabled` distinguishes the three cases cleanly. */ redeliverDelivery(webhookId: string, deliveryId: string): Promise; private notFound; } /** * Collection Registry Service * * Manages the `dynamic_collections` metadata table for both code-first * and UI-created collections. Provides schema hash-based change detection * for code-first collection syncing. * * Extends BaseRegistryService for shared CRUD, migration tracking, and utility patterns. * * @module services/collections/collection-registry-service * @since 1.0.0 */ /** Options for updating a collection. */ interface UpdateCollectionOptions { /** Source making the update. Used to enforce locking rules. */ source?: CollectionSource; } /** Input for registering a code-first collection during sync. */ interface CodeFirstCollectionConfig { slug: string; labels: { singular: string; plural: string; }; fields: DynamicCollectionInsert["fields"]; description?: string; tableName?: string; timestamps?: boolean; /** Whether the collection has the Draft/Published status feature enabled. */ status?: boolean; /** Resolved content-versioning config (or null when unversioned). */ versions?: DynamicCollectionInsert["versions"]; /** Cache-revalidation config (or null when the collection sets none). */ revalidate?: DynamicCollectionInsert["revalidate"]; /** Webhook recording policy (or null when the collection records, the default). */ webhooks?: DynamicCollectionInsert["webhooks"]; /** Whether collection-level i18n is enabled (mirrors `status`). */ localized?: boolean; admin?: DynamicCollectionInsert["admin"]; configPath?: string; /** * Provenance (D14): `"code"` for app code-first collections, `"plugin:"` * for plugin-contributed ones. Defaults to `"code"` when omitted. */ source?: CollectionSource; } /** Result of syncing code-first collections. */ interface SyncResult { created: string[]; updated: string[]; unchanged: string[]; errors: Array<{ slug: string; error: string; }>; } /** Options for listing collections. */ interface ListCollectionsOptions$1 extends BaseListOptions { source?: CollectionSource; migrationStatus?: MigrationStatus; } /** * Result of listing collections with pagination info. * * Declared as a `type` alias rather than an empty `interface` because the latter * triggers @typescript-eslint/no-empty-object-type. We intentionally keep this * named export so callers can import a domain-specific name even though it has * no extra members today. */ type ListCollectionsResult = BaseListResult; declare class CollectionRegistryService extends BaseRegistryService { protected readonly registryTableName = "dynamic_collections"; protected readonly resourceType = "Collection"; protected readonly tableNamePrefix = "dc_"; private permissionSeedService?; /** Invoked when code-first sync resolves a new `tableName` for an existing slug. */ private onTableNameChanged?; constructor(adapter: DrizzleAdapter, logger: Logger); protected getSearchColumns(): string[]; /** Set the PermissionSeedService for auto-seeding permissions on collection sync. */ setPermissionSeedService(service: PermissionSeedService): void; /** Register a callback fired when sync resolves a new `tableName` for a slug. */ setOnTableNameChanged(callback: (slug: string) => void): void; getCollectionBySlug(slug: string, executor?: unknown): Promise; getCollection(slug: string): Promise; getAllCollections(options?: ListCollectionsOptions$1): Promise; /** * Find pipeline-managed collections (code/plugin) that are no longer in the * current config — orphans left after a plugin or code collection was removed * (D14). They are RETAINED (never auto-dropped); `nextly prune` drops them * explicitly. Builder (`ui`) collections are excluded — they are managed via * the Visual Builder, not the code config. */ findOrphanedCollections(currentSlugs: string[]): Promise; listCollections(options?: ListCollectionsOptions$1): Promise; isLocked(slug: string): Promise; updateMigrationStatus(slug: string, status: MigrationStatus, migrationId?: string): Promise; updateMigrationStatusWithVerification(slug: string, tableName: string): Promise<{ verified: boolean; status: MigrationStatus; }>; getPendingMigrations(): Promise; registerCollection(data: DynamicCollectionInsert): Promise; updateCollection(slug: string, data: Partial, options?: UpdateCollectionOptions): Promise; deleteCollection(slug: string, options?: { force?: boolean; }): Promise; syncCodeFirstCollections(configs: CodeFirstCollectionConfig[]): Promise; registerCollectionInTransaction(tx: TransactionContext, data: DynamicCollectionInsert): Promise; /** * Rename the physical table when a code-first collection's `dbName` changes. * Renames only when old exists and new doesn't; warns when both exist; no-op * otherwise (boot auto-create handles the missing-table case). */ private renamePhysicalTable; private seedPermissionsForCollection; private labelsChanged; protected deserializeRecord(record: DynamicCollectionRecord | Record): DynamicCollectionRecord; } /** * Shared types for collection domain services. * * These types were originally defined in collection-entry-service.ts and are * used across all split services (access, hook, query, mutation, bulk). */ /** * Service result type for legacy format compatibility. * Used by collection services for consistent response structure. * * @public */ interface CollectionServiceResult { success: boolean; statusCode: number; message: string; data: T | null; /** * Canonical `NextlyError` code for failure envelopes that originate from a * NextlyError (or a mapped database error). The HTTP status alone is * ambiguous — 409 covers both DUPLICATE and CONFLICT — so boundary * translators (dispatcher, Direct API) use this to rebuild the precise * error instead of guessing from the status. */ code?: string; /** * The error's public data (failure only) -- the same object that reaches the * wire as `error.data`. Public by definition, unlike `logContext`, so it can * ride this shape safely, and carrying it is what lets a boundary rebuild an * error whose meaning lives there: a rate limit's retry interval, which the * route needs to emit `Retry-After`. */ publicData?: unknown; /** Translation key for the public message, when the thrower set one. */ messageKey?: string; /** * Per-field validation issues (failure only). Carried through the * result shape so the dispatcher and Direct API can rebuild the * canonical VALIDATION_ERROR envelope with field paths intact. */ errors?: Array<{ path: string; code: string; message: string; }>; /** * Whether this write appended a durable outbox event, independent of * `success`. A create/update/delete records the event inside its transaction, * then runs post-commit hooks: if one of those hooks throws, the write is * already committed but `success` is reported `false`. Post-write side effects * (the webhook fast-drain and retention pass) key off this flag, not `success`, * so a committed-but-hook-failed write still gets its immediate delivery while * a write that recorded nothing (validation/access failure) does not. */ eventRecorded?: boolean; /** * The cache tags/paths this write invalidates, computed at the write where the * slug/previous-slug/locale are in scope and flushed post-commit (alongside the * webhook fast-drain) through the registered {@link CacheRevalidator}. Absent * when the write recorded nothing or revalidation is disabled for the target. */ revalidationIntent?: RevalidationIntent; /** * Whether this operation committed a content write to the database — true for a * create/update/delete that reached and committed its transaction (even one * that opted out of BOTH recording and revalidation, and even one whose * post-commit hook then threw), false for a rejected request (validation / * access / not-found). The write-path retention pass keys off this so it runs * for every durable write yet skips a request that changed nothing, without * conflating `success` (a no-op reports success) with a committed write. */ committed?: boolean; } /** * User context for access control. * * Contains the minimum user information needed for evaluating access rules. * Passed to CRUD methods to enable collection-level access control. * * @public */ interface UserContext { /** Unique user identifier */ id: string; /** User's role (required for single-role role-based access rules) */ role?: string; /** * User's roles (many-to-many). Role-based access rules match if ANY of * these roles is allowed; `role` is folded in when present. */ roles?: string[]; /** User's display name (optional, for logging/auditing) */ name?: string; /** User's email address (optional, for logging/auditing) */ email?: string; /** Additional user data passed from the request */ [key: string]: unknown; } /** * Result of a bulk operation (create, update, or delete). * * Tracks successful and failed operations with structured per-item error * information for each failed entry. Uses partial success pattern where * some operations may succeed while others fail. * * Phase 4.5: redesigned to carry full success records (not just ids) and * structured per-item failures (canonical NextlyErrorCode + public-safe * message). The dispatcher decomposes this directly into the wire shape * via respondBulk; admin gets one round-trip with no re-fetch needed. * * Generic over T: * - For delete: T is `{ id: string }`. Records are gone; no point * materializing more than the id. * - For update/create: T is the full record. The records changed and * the client needs the new values. * * @public */ interface BulkOperationResult$1 { /** Records successfully processed. Full record for update; just `{id}` for delete. */ successes: T[]; /** Structured per-item failures. */ failures: Array<{ /** Identifier of the entry that failed (matches the request's input id). */ id: string; /** Canonical NextlyErrorCode value (e.g. "NOT_FOUND", "FORBIDDEN", ...). */ code: string; /** Public-safe message (NextlyError.publicMessage; no identifier or value echo). */ message: string; }>; /** Total number of entries attempted. */ total: number; /** Count of successful operations. */ successCount: number; /** Count of failed operations. */ failedCount: number; /** * Whether any item appended a durable outbox event, independent of * `successCount`. A per-item write can commit its row + event and still be * counted a failure when a post-commit hook throws (it returns * `success: false`), so a batch where every committed item hit that path has * `successCount === 0` yet owes deliveries. Post-write side effects key off * this so those events still get the immediate drain. */ eventRecorded?: boolean; /** * The cache-revalidation intents of every committed item in the batch, * aggregated so the post-commit flush busts all their tags at once. Absent * when nothing was recorded or revalidation is disabled for the target. */ revalidationIntents?: RevalidationIntent[]; } /** * Result from batch entry operations (createEntries, updateEntries, deleteEntries). * * Uses index-based error tracking for operations on arrays of entries. * * @public */ interface BatchOperationResult { /** Number of entries successfully processed */ successful: number; /** Number of entries that failed */ failed: number; /** IDs of successfully created/updated entries */ ids: string[]; /** Detailed error information for each failed entry */ errors: Array<{ index: number; error: string; }>; /** * Whether the committed batch appended any durable outbox event, independent * of `successful`. A per-item delete can commit its row + event in the shared * transaction and still be counted a failure when its afterDelete hook throws, * so a batch where every committed item hit that path has `successful === 0` * yet owes deliveries. Set only after the shared transaction commits. */ eventRecorded?: boolean; /** * The cache-revalidation intents of every committed item in the batch, * aggregated so the post-commit flush busts all their tags at once. Absent * when nothing was recorded or revalidation is disabled for the target. */ revalidationIntents?: RevalidationIntent[]; } /** * Options for bulk operations (create, update, delete). * * @public */ interface BulkOperationOptions { /** * Number of entries to process in each batch. * Larger batches are more efficient but use more memory. * @default 100 */ batchSize?: number; /** * If true, stops processing and rolls back the entire transaction * when any entry fails. If false, continues processing remaining entries. * @default false */ stopOnError?: boolean; /** * If true, skips hook execution (beforeCreate/afterCreate, etc.) * for each entry. Useful for high-performance imports. * @default false */ skipHooks?: boolean; } /** * Entry for bulk update operations. * * Contains the ID of the entry to update and the data to apply. * Supports partial updates - only specified fields will be modified. * * @public */ interface BulkUpdateEntry { /** ID of the entry to update */ id: string; /** Partial data to update (only specified fields will be modified) */ data: Record; } /** * CollectionAccessService — Collection-level access control for entry operations. * * Extracted from CollectionEntryService (6,490-line god file) as a leaf dependency * with no deps on other new split services. * * Responsibilities: * - Evaluate collection-level access rules (public, authenticated, role-based, owner-only, custom) * - RBAC gate (super-admin bypass → code-defined → DB permissions) * - Query constraint generation for owner-only read filtering * - Request context building from UserContext */ declare class CollectionAccessService extends BaseService { private readonly collectionService; private readonly accessControlService; private readonly rbacAccessControlService?; constructor(adapter: DrizzleAdapter, logger: Logger, collectionService: DynamicCollectionService, accessControlService: AccessControlService, rbacAccessControlService?: RBACAccessControlService | undefined); /** * Whether the caller's authorized role set makes them a super-admin. * * Public wrapper over the module predicate so other services (e.g. the * transaction owner-only safety nets) can honor the same "bypass stored * rules on every transport" contract without re-deriving super-admin * status. Keyed on authorized scope (`role`/`roles`), never the account id. */ isSuperAdmin(user?: UserContext): boolean; /** * Build RequestContext from UserContext for access control evaluation. */ buildRequestContext(user?: UserContext): RequestContext$1; /** * Extract access rules from collection metadata. * * Access rules can be stored in: * 1. `collection.accessRules` - Direct property (new format) * 2. `collection.schemaDefinition.accessRules` - Inside schema (legacy format) */ getAccessRules(collection: Record): CollectionAccessRules | undefined; /** * Point owner-only rules at the collection system owner column. * * A collection stores the owner in the auto-stamped `created_by` column, and * both `created_by` and its camelCase alias `createdBy` are reserved as field * names — so a rule naming `createdBy` (the old documented default) can only * mean that column. Rewrite either spelling to DEFAULT_OWNER_FIELD so every * downstream owner check (read query filter, document compare, tx safety net) * targets the column the create path actually stamps; any other `ownerField` * is a genuine custom field and is left untouched. Returns a shallow clone * only when a rewrite is needed, so the stored collection is never mutated. */ private normalizeCollectionOwnerFields; /** * Check collection-level access for an operation. * * Called FIRST before any other security checks (hooks). * Returns early with 403 if access is denied. * * When `overrideAccess` is true (a trusted-server / system write), access * control is bypassed entirely (returns null). * * When `routeAuthorized` is true, the route middleware already ran the coarse * RBAC / code-access gate, so only THAT gate is skipped here — the stored * collection access rules (owner-only / role-based / authenticated / custom) * are still evaluated with the real user. This is why a route write cannot * skip owner-only enforcement: `overrideAccess` stays false, only the * redundant RBAC re-check is elided. */ checkCollectionAccess(collectionName: string, operation: AccessOperation, user?: UserContext, documentId?: string, document?: Record, overrideAccess?: boolean, routeAuthorized?: boolean, authenticatedScope?: AuthenticatedScope, deferStoredRuleEval?: boolean, executor?: unknown): Promise | null>; /** * Whether a stored access rule's decision depends on the specific document — * `owner-only` (compares the owner column) or `custom` (a function that may * inspect `id`/`data`). These must be re-judged against the row-locked row, not * the docless pre-resolve. `public`/`authenticated`/`role-based` are fully * decided without a document, so they are excluded. */ isDocumentDependentRule(rule: { type?: string; } | undefined): boolean; /** * Pre-resolve whether a collection has a document-dependent (owner-only or * custom) publish/unpublish rule that must be judged against the specific row * being transitioned. Runs on the pooled connection BEFORE a write transaction, * so the in-transaction check needs no metadata read (mirrors the permission * pre-resolve used by the transaction/batch paths). * * Returns the already-fetched rules + user when such a rule applies, or null * when it does not: a super-admin SESSION bypasses stored rules — but NOT a * scoped API key it owns (matching {@link checkCollectionAccess}) — and a * collection with no document-dependent publish/unpublish rule has nothing to * re-enforce. role-based/authenticated/public rules are fully decided by the * docless permission pre-resolve, so they are intentionally excluded here. The * caller is responsible for skipping this on an `overrideAccess` write. */ resolveTransitionDocumentRule(collection: Record, user: UserContext | undefined, authenticatedScope?: AuthenticatedScope): { accessRules: CollectionAccessRules; user: UserContext | undefined; } | null; /** * Evaluate the stored document-dependent (owner-only or custom) publish/ * unpublish rule for a transition against an ALREADY-FETCHED (row-locked) * document, with no metadata or permission read — safe to call inside a * transaction. Returns a 403 result when the rule denies, or null when it * allows or no document-dependent rule governs the operation. * * This closes the gap where the docless permission pre-resolve lets a * document-dependent publish/unpublish through: owner checks defer without a * document, and a custom rule is judged on empty `id`/`data`. So a caller who * may update another user's row could otherwise batch-publish or unpublish it * under the transaction. The row's own `id` is passed so a custom rule that * keys off the document id sees the real value. */ evaluateTransitionDocumentRule(accessRules: CollectionAccessRules, operation: "publish" | "unpublish", user: UserContext | undefined, document: Record): Promise | null>; /** * Get access query constraint for read operations. * * For owner-only access rules on read operations, the AccessControlService * returns a query constraint instead of a boolean. */ getAccessQueryConstraint(collectionName: string, user?: UserContext, overrideAccess?: boolean, authenticatedScope?: AuthenticatedScope): Promise | null>; /** * Resolve the owner-only constraint for a single * operation as a flat `{ field, value }` pair so callers can fold it * directly into a Drizzle `WHERE` clause. Returns `null` when the * rule is not `owner-only`, when the caller is bypassing access via * `overrideAccess`, or when no user context is present. * * Read uses get the constraint from the access-control service's * `query` channel; mutate operations (update / delete) read the rule * directly because the access service only emits the `query` channel * for reads. The result is the same for the consumer either way: * if non-null, the entry fetch / mutate must include this predicate * in its WHERE clause so a non-owner sees a 404, not a 403, and IDOR * by id-iteration returns nothing. * * Use the existing `evaluateAccess` flow for the boolean * (allowed / denied) decision; this helper is purely about the * predicate that goes into SQL. */ getOwnerConstraint(collectionName: string, operation: AccessOperation, user?: UserContext, overrideAccess?: boolean, authenticatedScope?: AuthenticatedScope, executor?: unknown): Promise<{ field: string; value: string; } | null>; } /** * Database Lifecycle Hooks System - Context Builder * * Utility functions for building HookContext objects from request data. * Provides a clean API for creating contexts with all necessary metadata. * * @module hooks/context-builder * @since 1.0.0 */ /** * Options for building a hook context */ interface BuildContextOptions { /** * Collection name (e.g., "posts", "users") */ collection: string; /** * Operation type */ operation: "create" | "read" | "update" | "delete"; /** * Data being operated on */ data?: T; /** * Original data before changes (for update operations) */ originalData?: T; /** * User ID performing the operation (if authenticated) */ userId?: string; /** * Additional user data */ user?: { id: string; email?: string; [key: string]: unknown; }; /** * Shared context for passing data between hooks * If not provided, an empty object is created */ context?: Record; /** * Request metadata and API access (headers, query params, nextly instance) */ req?: { headers?: Record; query?: Record; nextly?: Nextly; }; /** * Transaction-bound Drizzle executor forwarded onto the context, so hooks that * read the database run on the caller's transaction connection instead of the * pool (see {@link HookContext.executor}). Omitted outside a transaction. */ executor?: unknown; } /** * Pre-built Hook Templates * * Common hook patterns that can be configured via the UI for UI-created collections. * These hooks provide out-of-the-box functionality without requiring code-first implementation. * * @module hooks/prebuilt * @since 1.0.0 * * @example * ```typescript * import { prebuiltHooks, getPrebuiltHook } from '@nextly/hooks/prebuilt'; * * // Get all available hooks * console.log(prebuiltHooks.map(h => h.name)); * * // Get a specific hook by ID * const autoSlugHook = getPrebuiltHook('auto-slug'); * ``` */ /** * Context passed to pre-built hook execute functions. * * Extends HookContext with additional properties needed for * pre-built hook execution. */ interface PrebuiltHookContext extends HookContext { /** * The operation type (create, read, update, delete). * Inherited from HookContext but made explicit for clarity. */ operation: "create" | "read" | "update" | "delete"; /** * Database query function for uniqueness checks. * Returns true if a matching value exists, false otherwise. */ queryDatabase?: (params: { collection: string; field: string; value: unknown; caseInsensitive?: boolean; excludeId?: string; }) => Promise; } /** * Stored Hook Executor * * Executes pre-built hooks configured via the Admin UI. * Loads stored hook configurations from collection records and executes * matching pre-built hooks with their stored configurations. * * @module hooks/stored-hook-executor * @since 1.0.0 * * @example * ```typescript * import { StoredHookExecutor } from '@nextly/hooks/stored-hook-executor'; * * const executor = new StoredHookExecutor(); * * // Execute stored hooks for a collection * const modifiedData = await executor.execute( * 'beforeCreate', * collection, * hookContext * ); * ``` */ /** * Result of stored hook execution. * * Contains the potentially modified data and metadata about the execution. */ interface StoredHookExecutionResult { /** * The data after all hooks have executed. * May be modified by before* hooks. */ data: T | undefined; /** * Number of hooks that were executed. */ executedCount: number; /** * IDs of hooks that were skipped (disabled or not found). */ skippedHookIds: string[]; /** * Hooks in a post-commit phase that threw. Reported rather than raised: the * write is already durable, so failing the operation would invite a retry * that writes it a second time. */ failures: SideEffectHookFailure[]; } /** * Options for stored hook execution. */ interface StoredHookExecutorOptions { /** * If true, logs debug information about hook execution. * @default false */ debug?: boolean; } /** * StoredHookExecutor handles execution of pre-built hooks configured via UI. * * This executor is designed to run AFTER code-registered hooks in the * HookRegistry, providing a clear execution order: * 1. Code-registered hooks (via HookRegistry) * 2. Stored/UI-configured hooks (via StoredHookExecutor) * * **Features:** * - Loads stored hooks from collection record * - Maps virtual hook types (beforeChange → beforeCreate, beforeUpdate) * - Executes hooks in order (by `order` field) * - Chains data modifications for before* hooks * - Skips disabled hooks * - Provides detailed error messages on failure * * **Error Handling:** * - If any hook throws, execution aborts immediately * - Error includes hook ID for debugging * - Follows same pattern as HookRegistry * * @example * ```typescript * const executor = new StoredHookExecutor(); * * // In CollectionEntryService.createEntry(): * // After code hooks run via hookRegistry.execute() * const result = await executor.execute( * 'beforeCreate', * collection, * { ...context, data: modifiedData } * ); * * // Use result.data for database insert * ``` */ declare class StoredHookExecutor { private options; constructor(options?: StoredHookExecutorOptions); /** * Execute stored hooks for a specific hook type. * * Loads enabled hooks from the collection record that match the given * hook type and executes them in order. Data modifications chain * through before* hooks. * * @template T - Type of the data being operated on * @param hookType - The hook type to execute (e.g., 'beforeCreate', 'afterUpdate') * @param storedHooks - Array of stored hook configurations from the collection * @param context - The hook context with current data and metadata * @returns Execution result with potentially modified data * @throws Error if any hook fails (includes hook ID in message) * * @example * ```typescript * const result = await executor.execute( * 'beforeCreate', * collection.hooks ?? [], * { * collection: 'posts', * operation: 'create', * data: { title: 'My Post' }, * user: { id: 'user-123' }, * context: {} * } * ); * * console.log(result.data); // { title: 'My Post', slug: 'my-post' } * console.log(result.executedCount); // 1 * ``` */ execute(hookType: HookType, storedHooks: StoredHookConfig[] | undefined | null, context: PrebuiltHookContext): Promise>; /** * Check if there are any enabled hooks for a given hook type. * * Useful for performance optimization - skip execution if no hooks match. * * @param hookType - The hook type to check * @param storedHooks - Array of stored hook configurations * @returns True if there are enabled hooks for this type * * @example * ```typescript * if (executor.hasHooks('beforeCreate', collection.hooks)) { * const result = await executor.execute('beforeCreate', collection.hooks, context); * } * ``` */ hasHooks(hookType: HookType, storedHooks: StoredHookConfig[] | undefined | null): boolean; /** * Get stored hooks that match a specific hook type. * * Handles virtual hook types (beforeChange, afterChange) by checking * if they map to the requested actual hook type. * * @param hookType - The actual hook type (e.g., 'beforeCreate') * @param storedHooks - Array of stored hook configurations * @returns Hooks that should run for this hook type */ private getMatchingHooks; } /** * CollectionHookService — Hook context building for collection entry operations. * * Extracted from CollectionEntryService (6,490-line god file) as a leaf dependency * with no deps on other new split services. * * Responsibilities: * - Build HookContext for code-registered hooks * - Build PrebuiltHookContext for UI-configured stored hooks * - Resolve Nextly Direct API instance for hook contexts * - Extract stored hook configurations from collection metadata */ /** * Parameters for querying field uniqueness in the database. * Used by stored hooks to validate field uniqueness constraints. */ interface QueryDatabaseParams { collection: string; field: string; value: unknown; caseInsensitive?: boolean; excludeId?: string; executor?: unknown; } declare class CollectionHookService { readonly hookRegistry: HookRegistry; readonly storedHookExecutor: StoredHookExecutor; constructor(hookRegistry: HookRegistry); /** * Resolve the Nextly Direct API instance for hook contexts. * * Returns the Nextly instance from the DI container if available, * or undefined if not yet initialized. */ resolveNextlyForHooks(): unknown; /** * Build a HookContext with the Nextly Direct API instance attached to `req.nextly`. * * Wrapper around `buildContext()` that automatically injects the Nextly * instance into the `req` property of the hook context. */ buildHookContext(options: BuildContextOptions): HookContext; /** * Build a PrebuiltHookContext from HookContext components. * * PrebuiltHookContext extends HookContext with explicit operation type * and database query function for uniqueness validation. * * @param queryDatabase - Function to check field uniqueness (injected by caller) */ buildPrebuiltHookContext(collectionName: string, operation: "create" | "read" | "update" | "delete", data: unknown, queryDatabase: (params: QueryDatabaseParams) => Promise, user?: UserContext, sharedContext?: Record, executor?: unknown): PrebuiltHookContext; /** * Run the `beforeChange` phase over the data a write is about to persist. * * Called from every write path immediately before that path's field-level * `beforeChange` hooks, which is the point the validation gate has just been * passed. Collection-level handlers run first, then stored ones -- the same * order the pre-validation phase uses, so the two read alike. * * The result is applied ONTO `data` rather than returned. A handler returning * its own object still replaces the document -- keys it dropped are dropped -- * but the object identity is preserved, because every caller has already * handed this object to slug generation, write access and validation, and * some hold it in a closure. Reassigning at six call sites is where that goes * wrong quietly. */ runBeforeChange(options: { collection: string; operation: "create" | "update"; data: Record; storedHooks: StoredHookConfig[]; queryDatabase: (params: QueryDatabaseParams) => Promise; user?: UserContext; sharedContext?: Record; /** * The stored row an update is changing. Carried because a handler comparing * old against new is the ordinary use of the phase, and the context this * builds is the only place it can come from. */ originalData?: Record; executor?: unknown; }): Promise; /** * Extract stored hooks from a collection record. * * Stored hooks are configured via the Admin UI and stored in the * `hooks` JSONB column. Returns empty array if no hooks are configured. */ getStoredHooks(collection: Record): StoredHookConfig[]; } /** * CollectionMutationService — Write/mutation operations for collection entries. * * Extracted from CollectionEntryService (6,490-line god file) to handle all * create, update, and delete operations with hooks, validation, and relationships. * * Responsibilities: * - Create new entries with hooks, validation, relationships * - Update existing entries with hooks, validation * - Delete entries with hooks and cascading * - Transaction-aware variants of all CRUD operations * - Field uniqueness checking for stored hook validation * - Single-entry transaction helpers for batch operations */ /** * A caller's publish/unpublish authorization for a collection, resolved ONCE on * the pooled connection BEFORE a write transaction opens. Each field holds the * 403 result to return if that op is attempted, or `null` when the op is allowed * (or the collection has no lifecycle / the write is trusted). * * The transaction/batch write paths consult this under the row lock instead of * reading permission storage inside the transaction: the permission a write can * require is fully determined by the FINAL status it persists (only `"published"` * can publish; any other explicit value can only unpublish a published row), so * resolving both ops up front lets the in-transaction step classify the * transition against the row-locked status and look up the answer with no DB read * — closing both the TOCTOU window and the pooled-read-inside-a-transaction stall. */ interface TransitionAuthorization { publishDenied: CollectionServiceResult | null; unpublishDenied: CollectionServiceResult | null; /** * Pre-fetched inputs for the document-dependent (owner-only) publish/unpublish * check, or `null` when none applies. The permission fields above cannot judge * an owner-only rule up front because it needs the specific row (which is only * known under the lock); this carries the rules + user so the in-transaction * step can evaluate the owner against the row-locked document with no metadata * or permission read. `null` for a trusted write, a super-admin session, or a * collection without an owner-only transition rule — in which case the * transaction path skips the document check entirely. */ documentRule: { accessRules: CollectionAccessRules; user: UserContext | undefined; } | null; } declare class CollectionMutationService extends BaseService { private readonly fileManager; private readonly collectionService; private readonly relationshipService; private readonly accessService; private readonly hookService; private readonly fieldGroupDataService?; /** * Normalized localization config (i18n M5). When set and a collection is localized, writes * route translatable field values to the companion `_locales` row for the write's locale. * Absent → non-localized behavior (unchanged). */ private readonly localization?; constructor(adapter: DrizzleAdapter, logger: Logger, fileManager: CollectionFileManager, collectionService: DynamicCollectionService, relationshipService: CollectionRelationshipService, accessService: CollectionAccessService, hookService: CollectionHookService, fieldGroupDataService?: FieldGroupDataService | undefined, /** * Normalized localization config (i18n M5). When set and a collection is localized, writes * route translatable field values to the companion `_locales` row for the write's locale. * Absent → non-localized behavior (unchanged). */ localization?: SanitizedLocalizationConfig | undefined); /** * Stateless version-capture service. Records a durable version snapshot * inside the write transaction when the collection opts into versioning, so * the version commits atomically with the content write. */ private readonly versionCapture; /** * Emit the document-level status events for one transition (post-commit). * * Fires the general `statusTransition` event (the seam workflows/item 9 build * on) plus the specific `statusChanged` / `published` events existing * subscribers already listen on, so current behavior is preserved. Create as * `published` has no prior status to change from, so it passes * `emitStatusChanged: false` to keep emitting only `published` (and now the * general transition), never `statusChanged`. * * `locale` is set only for a per-locale (companion `_status`) transition on a * localized collection; when present it rides on every emitted payload so a * subscriber can tell a single-language transition apart from a document-wide * one (a document-wide publish carries no `locale`). */ /** * The collection's field tree with component references expanded. * * A component reference names its target by slug and carries no inline * children, so without this the secret/hidden walk never sees fields declared * inside a component and their values would ship in the event payload. */ private webhookFieldTree; /** * {@link webhookFieldTree}, but SKIPPED when the collection opted out of * recording. Component expansion issues a registry read per component slug, and * `recordMutationEvent` short-circuits on the opt-out before it ever reads * `fields` — so for a `webhooks: false` collection that work is pure waste, and * a scalar write should never be able to fail on a component/relation read it * does not need. Returns the raw fields unchanged in that case (they go unread). */ private webhookFieldTreeIfRecording; /** * Emit a collection's curated create event (e.g. `form.submission.created`) * when it declared `webhooks.emit`, INSIDE the caller's transaction so it * commits with the row. The payload is a default-deny projection of the * created document, so a collection that opted its `entry.*` events out for * PII ships only the allowlisted fields, on a resource kind (e.g. `form`) the * per-collection opt-out does not gate. Returns whether a row was recorded so * the caller folds it into its fast-drain gate; a no-op for ordinary * collections. Applied at every create seam so the collection-level contract * holds for the direct, transaction, and bulk create paths alike. */ private recordCuratedCreateEvent; /** * Record the lifecycle status events for one transition * (`entry.published`/`entry.unpublished`/`entry.status_changed`) into the * outbox, INSIDE the caller's write transaction so they commit atomically with * the content write and inherit the recording opt-out (each call routes through * `recordMutationEvent`, which short-circuits on a `webhooks: false` * collection). `statusEventsFor` decides the event set; a no-op transition * (`from === to`, or a write that set no `status`) records nothing. Reuses the * document/`previous`/`fields` the surrounding write already assembled for its * `entry.created`/`entry.updated` event, so an opted-out write pays nothing * extra. Returns whether any event was appended, so the caller folds it into * the same `eventRecorded` signal that gates the fast-drain and retention pass. */ private recordStatusEvents; /** * The read-shape parent document a programmatic (tx-API / batch / publish) * write event carries: JSON container fields parsed, then password hashes and * the internal owner column (created_by) stripped — the same server-side * fields the interactive create/update paths remove before building their * event, so a stable user id never leaves in a webhook envelope. Operates on a * shallow copy so the caller's row is not mutated. Many-to-many/component * subtrees are not assembled here (the parent columns are the event payload on * these paths); the full relational assembly rides the version-capture work. */ private readShapeEventDocument; /** * Assemble a full read-shape document for a row written on a caller's * transaction: the already read-shaped parent columns plus a fresh read of the * row's component subtrees and many-to-many id arrays on that same * transaction. Returns both the composed parts (so a caller can index a * version snapshot from them without a second relations read) and the * assembled document (the shape the outbox event carries). * * The tx-API and batch write paths build only the parent row inline; routing * their event payload through here gives it the same relational completeness * the interactive paths carry. These paths route no localized write, so the * relations are read without a locale (a single set of values). * * `needsRelations` gates the relational read: it is skipped when neither a * version nor an event will consume the result (versioning off AND the * collection opted out of webhooks). `buildFullSnapshotRelations` issues a * query per component and m2m field and deliberately fails the write on a read * error, so running it for a write that consumes nothing would add avoidable * per-item query cost and could roll back an otherwise valid scalar write on an * unrelated relation read. When skipped, the parent columns are the document. */ private readTxDocumentParts; /** * Capture one durable version snapshot on a caller's transaction from parts * already assembled by {@link readTxDocumentParts}, when the collection opts * into versioning. A no-op otherwise, so a tx-API or batch write into a * non-versioned collection stays free of the tagging walk. * * The snapshot commits atomically with the content write on the caller's * transaction — history never records a write that later rolls back. These * paths route no localized parent write, but an unlocalized collection can * still embed a localized component, whose subtree was read at the default * locale; the version is then tagged with that locale so a restore knows which * language to write the component into (mirroring the interactive paths). A * snapshot with no component state carries no locale. */ private captureTxVersion; /** * Assemble a removed entry as the read shape the create/update events carry — * JSON container fields parsed, component subtrees and many-to-many id arrays * populated, password and system-owner fields stripped — so a delete event * reports the document in a shape consistent with every other event. Reads the * relations on the delete transaction, so the caller MUST build this BEFORE the * cascade delete removes them. */ private buildDeletedDocument; private transitionStatus; /** * The document a validator is shown. * * A relationship read at a populating depth comes back as the related row, * and a multi-target one wrapped with the collection it names. A field's * public value is the document id, and a custom validator is written against * that — handed a row it compares an object to a string, or calls a string * method on it and throws. * * Reduced on a detached copy rather than in place, because the submitted * shape is what the hooks between here and storage still expect to see. */ private validationView; /** * Build the locale-aware inputs for {@link validateEntryData} on a localized-collection write * (i18n M5b). `required` on a localized field is enforced only for the default-language row so the * "publish default now, translate later" workflow proceeds; shared required fields are always * enforced. For a non-localized collection this yields an empty set and enforce=true, so the * canonical validator behaves exactly as it does elsewhere. Localized field names come from the * companion schema, so a localized collection that has not been migrated yet (localized columns * still on the main table) treats no field as localized, matching the pre-migration behavior. */ private localizedRequiredContext; /** * reject an unrecognized write locale with a 400 instead of silently mapping it to * the default locale (which would write the translatable values into the DEFAULT companion * row, potentially overwriting real default content). Returns a 400 result, or null when the * locale is absent/valid or localization is off. */ private rejectInvalidWriteLocale; /** * Upsert the companion `_locales` row for `(parentId, locale)` with the provided localized * columns (i18n M5, updateEntry). Only the provided columns are written — an existing row for * another locale, or other localized fields on this locale's row, are left untouched. Uses the * PK `(_parent, _locale)` conflict target. Runs inside the caller's transaction via `tx.execute`. */ private upsertCompanionRow; /** * Split `entryData` (snake_case keys) into main-table data and companion data for a localized * collection: localized columns move to `companionData` and are removed from `mainData` (the * migrated main table no longer has them). Returns `null` when the collection isn't localized * or the companion table doesn't exist yet (dev/unmigrated → localized cols stay on main). */ /** * The locale a component subtree in a snapshot belongs to. * * Component tables are per-locale whether or not their parent is, and a write * that names no locale still reaches them at the configured default — the * component read and write both resolve `undefined` that way. Recording null * would leave that snapshot unplaceable, so the default is made explicit * here. Without localization configured there are no per-locale rows and * nothing to record. */ private componentSnapshotLocale; private splitLocalizedWriteData; /** * Turn post-hook update input into the column/relation shapes the write path * persists, mutating `data` in place into the main-row payload and returning * the pieces that live outside it. Relationships and uploads are reduced to * ids; component and many-to-many fields are pulled out of `data` (they store * in their own tables); JSON, date, slug, and upload columns are serialized. * * Pure and free of database access, so it runs the same off-transaction for a * normal write and inside the transaction when a publish promotes an * accumulated working draft — the draft's stored snapshot is shaped through * this exact path so promoted content reaches the row identically to a direct * write. `manyToManyFields` is passed in rather than recomputed because the * caller reuses the same list for the junction rewrite later in the write. */ private shapeWriteParts; /** * Return a shallow copy of `row` with JSON-backed field values (richtext, * blocks, array, group, json) parsed from their stored string form, matching * the read shape so a version snapshot equals a normal read. Non-JSON and * already-parsed values pass through; a parse failure keeps the raw string. * Never mutates the input. */ private deserializeJsonFieldsForSnapshot; /** * Read the entry's component subtrees + many-to-many id arrays for a version * snapshot, using the WRITE TRANSACTION's connection (read-your-writes, #226) * so the components and junction rows just written in the same transaction are * visible on every dialect. The read path returns the full read shape — ids * populated, JSON parsed, password fields stripped — and an empty relationship * reads as `[]`, so the snapshot matches a normal read with no in-memory * overlay and cannot leak component password hashes. A read failure fails the * capture (the whole transaction rolls back) rather than persisting a * knowingly-incomplete snapshot the caller cannot tell is incomplete. */ /** * The write locale's translatable values as the companion row currently holds * them, with no locale fallback so the caller sees exactly this locale. * * The main row never stores translatable values, so a snapshot built from it * alone omits every localized field. Reading through the transaction handle * makes the result reflect whatever the caller has already written in this * transaction (nothing, before the companion upsert; the new values after it). * Undefined values are skipped so an untranslated field is not written as * `undefined` over the main-row value. */ private readCompanionLocalizedValues; /** * Every distinct slug value across a localized collection's locales for one * entry. A localized `slug` field can differ per locale, so a publish-all or a * delete must bust each locale's URL, not just the default one. Returns [] for * a non-localized collection, one whose `slug` is not localized, or one with no * configured locales. Bound to the caller's transaction connection so the read * does not re-enter the pool from inside the transaction. * * Never throws: this feeds cache invalidation, not the write itself. On the * publish path it runs post-commit, so a thrown error would wrongly report a * committed publish as failed; on the delete path it must not abort a delete. * A read failure degrades to [] (the collection/id tags — and, since reads are * id-tagged, every locale's page — still bust) and is logged rather than * silently dropped. */ /** * Resolve a collection's companion readiness on the pooled connection. * * Warming only — it judges nothing. Its value is the verdict it leaves behind for the * in-transaction reads that follow, which cannot resolve one themselves. */ private warmCompanionReadiness; /** * Resolve, on the pooled connection, every companion verdict a write for this collection needs: * the collection's own, and one for each field-group type its schema can hold. * * Public because the only place this can run is somewhere the caller controls. A method that * receives a transaction cannot do it for itself: resolving issues a query, a query against a * missing relation aborts the whole transaction on PostgreSQL, and a pooled probe taken while a * transaction is open waits for a connection that transaction will not release until it ends. * So it has to happen before the transaction opens. * * Skipping it is exactly what makes it worth calling. Nothing throws — an unresolved verdict * reads as unusable, so the write commits normally while its durable version snapshot and its * outbound event quietly omit every localized component value. That omission surfaces from a * consumer of the event, long after the snapshot has become the historical record and stopped * being reconstructable. * * Read-only and idempotent: safe to call more than once, and for a collection that is not * localized at all. */ warmLocalizedReadiness(collectionName: string): Promise; /** * Remove a document's pending working-draft sidecar under the same parent-row * lock a draft save takes. * * A status-less save upserts the working draft while holding the parent row's * lock (see the working-draft branch of updateEntry). Discarding has to take * the same lock: without it, a save that commits between a discard's * authorization checks and its delete would have its brand-new draft removed, * and both requests would report success, silently losing that edit. Running * the delete inside a transaction that locks the parent row serializes it with * those saves. The lock is a no-op where row locking is unavailable (SQLite, * which already serializes writers). * * Authorization is the caller's concern: the discard handler establishes read * and update on the document before this runs. Deleting when no working draft * exists is a no-op, not an error. */ discardWorkingDraft(params: { collectionName: string; entryId: string; }): Promise; private readCompanionSlugsAllLocales; /** * The write locale's per-locale `_status`, or null when the companion row has * none. * * Read with raw `tx.execute` (matching upsertCompanionRow / publishAllLocales): * the companion `_locales` table is not in the Drizzle schema, and the CRUD * helpers camelCase result keys, which would rename `_status`. */ private readCompanionStatus; /** * Whether this document is already reachable by the public, ignoring what the current write is * about to do to one locale. * * The marker records a document's FIRST publication, and a localized document can be public * through its main row or through any one of its translations. A write that publishes a single * locale therefore cannot tell, from its own transition alone, whether the document is becoming * public or already was — and the rows where that matters are the upgraded ones, whose marker is * null because the history was never recorded rather than because they were never public. * * Reads through the transaction's Drizzle handle via the same companion scan the publish path * uses, so there is one way to ask a companion for its per-locale statuses. * * `exceptLocale` is the locale this write is changing: its committed status is the "before" of * the transition being judged, so counting it here would make every publish look like a * republish. */ private isDocumentAlreadyPublic; /** * Assemble the document a draft promotion actually persists: the draft with the * caller's scalars overlaid, the caller's single-component patches merged onto * the draft's components (a patch wins per sub-field, recursing into nested * single components; a dynamic zone, a repeatable component, and a many-to-many * set are replaced whole). `shapeWriteParts` extracts component and m2m fields * out of the caller payload before promotion, so field-level write access and * validation would otherwise judge the draft's OLD copy of those fields while * the caller's copy is folded back in and persisted. Building the full document * here lets the access and validation passes see the real final values, at every * depth, for column, component, and many-to-many fields alike. */ private assemblePromotedDocument; /** * Shape a working-draft snapshot into the read document the response and hooks * see, the same way the read overlay does: prune it to the current schema * (dropping a field a later change removed and the single-component type markers * the persisted snapshot keeps for promotion), copy back the immutable id and * timestamp columns `buildRestorePayload` holds out, and rehydrate JSON date * strings to Date at every depth. Used for the newly accumulated draft and for * the prior draft the afterUpdate hooks compare against. */ private shapeDraftForResponse; /** * Overlay `patch` onto `base`, recursively merging single (non-repeatable) * component objects instead of replacing them. * * A patch-shaped save carries only the sub-fields it changed, so replacing a * component's whole object would drop sub-fields an earlier save set. Recurses * according to the resolved component schemas so a component nested inside a * component is merged at every depth. A dynamic zone (array), a repeatable * component, and a scalar are replaced whole (patch wins). */ private mergeSingleComponentPatches; /** * The document parts a version records, with component types tagged. * * A separate shape from what the outbox carries: the same parts feed both, * and the marker belongs only to the snapshot. */ private snapshotPartsFor; /** * Delete password-field values from component instances in a snapshot's * component map, descending through nested components. `stripPasswordFieldValues` * handles a component instance's own passwords and any nested in a * group/repeater, but cannot follow a component referenced by slug; this * resolves each instance's schema (from the tagged `_componentType`, or the * field's single declared component) and recurses so a password two components * deep is removed too. Mutates in place: on every path that reaches here the * component data has already been saved (promote) or will never be saved * (draft edit), so stripping the snapshot copy cannot affect a live write. */ private stripComponentPasswordsInPlace; private buildFullSnapshotRelations; /** * Serialize hasMany relationship arrays to JSON strings before insert/update. * * Code-first `relationship({ hasMany: true })` fields are stored as a JSON * column on the parent table (see runtime-schema-generator's `case "json"`). * SQLite uses a plain `text` column for JSON, so the caller must stringify; * PostgreSQL `jsonb` and MySQL `json` accept either a JS array or a string. * Unconditional stringification keeps all three dialects on the same path. * * Mutates `finalData` in place. Idempotent: arrays become strings; existing * strings (e.g. when the caller pre-serialized) are not double-encoded. */ private serializeHasManyRelationships; /** * Redact a persisted entry before it is returned to the client. Drops * write-only password hashes and any field the caller may write but not * read (`access.read`). The query path already applies both, so every * mutation response must run the same redaction or a create/update could * echo back a value the reader is denied — the write and read rules are * independent, so a field can be writable yet read-denied. * * `overrideAccess` normally skips read redaction (a trusted server-context * caller asked for the full document). The REST dispatcher, however, sets * `overrideAccess` only to skip the collection-level re-check after route * auth — it is NOT a trusted read context, so `routeAuthorized` forces the * response to still be redacted to what the authenticated user may read, * matching the query path for the same caller. */ private redactResponseFields; /** Resolve the physical table for a collection, honoring `dbName` overrides. */ private resolveTableName; /** * Wrapper around checkFieldUniqueness that matches the QueryDatabaseParams * signature expected by CollectionHookService.buildPrebuiltHookContext. */ private readonly queryDatabaseFn; /** * Check if a field value already exists in a collection. * * Used by stored hooks for uniqueness validation. Can optionally exclude a specific document * (useful for update operations where we want to exclude the current document). * * @param collectionName - Name of the collection to query * @param field - Field name to check for uniqueness * @param value - Value to check for duplicates * @param caseInsensitive - Whether to perform case-insensitive comparison * @param excludeId - Optional document ID to exclude from the check (for updates) * @returns Promise - true if a duplicate exists, false otherwise */ checkFieldUniqueness(collectionName: string, field: string, value: unknown, caseInsensitive?: boolean, excludeId?: string, executor?: unknown): Promise; /** * Fill the auto-injected `slug` and `title` columns on a create payload. * * defineCollection injects a required, unique `slug` and a NOT NULL `title` * into every collection. When the caller omits them we derive them here: the * slug from the title (or name, or a unique fallback token), the title from * the name or the slug. * * A GENERATED slug is deduped so a repeated title auto-increments (`hello`, * `hello-2`, …) — the WordPress/Ghost convention. An EXPLICITLY provided slug * is only sanitized and kept as-is: the caller asserted a canonical value, so * a collision surfaces as the normal unique-constraint conflict rather than a * silent rename. `isSlugTaken` is supplied by the caller so the uniqueness * check runs on the correct executor — the shared connection for a plain * create, or the enclosing transaction (which sees its own pending rows) for * a transactional create. Runs before field-level write access so a caller * denied `title`/`slug` write does not have them reintroduced. Mutates * `finalData`. */ private applyGeneratedSlugAndTitle; /** * Derive a unique slug from the title (or name), falling back to a * collision-proof token. `generateSlug` strips everything outside [\w-], so a * CJK/emoji/punctuation-only title (or a missing one) yields an empty base; * the `entry--` fallback keeps the required, unique `slug` column * populated instead of failing required-field validation. */ private deriveSlug; /** * Re-sanitize `slug` after field-level beforeValidate hooks run. Those hooks * execute after slug generation, so a hook that sets `slug` (for example from * the title) could introduce an unsanitized value that would otherwise be * validated and stored verbatim. Normalizing here keeps the stored slug * URL-safe; it is idempotent for an already-clean slug. When the hook value * sanitizes to empty (a CJK/emoji/punctuation-only string), it derives a * valid slug from the title just like `applyGeneratedSlugAndTitle` does for * an explicit slug that sanitizes away, rather than leaving the un-sanitized * value to be stored verbatim. */ private reSanitizeSlug; /** * Return a slug that is free, appending `-2`, `-3`, … until `isSlugTaken` * reports it available. Bounded so a pathological data set can't spin * forever; the final fallback appends a timestamp that is effectively * collision-proof. The unique constraint on the column remains the ultimate * guard against a concurrent race between the check and the insert. */ private dedupeSlug; /** * Create a new entry. * Applies collection-level access control and hooks. * * Security checks are applied in order: * 1. Collection-level access (AccessControlService) * * @param params - Collection name and optional user context * @param body - Entry data to create * @returns Created entry or error */ createEntry(params: { collectionName: string; user?: UserContext; /** * Who performed the write, recorded on the outbox event. Set by the * transport; absent for internal writes, which record as `system`. */ actor?: RequestActor; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows. See {@link RelatedRowReadContext.trusted}. */ trusted?: (collection: string) => boolean; /** Write locale (i18n M5): translatable values are stored for this language. */ locale?: string; routeAuthorized?: boolean; context?: Record; authenticatedScope?: AuthenticatedScope; }, body: Record, depth?: number): Promise; /** * Update an existing entry. * Applies collection-level access control and hooks. * * Security checks are applied in order: * 1. Collection-level access (AccessControlService) * * @param params - Collection name, entry ID, and optional user context * @param body - Update data * @returns Updated entry or error */ /** * Publish ALL languages of an entry at once (i18n M7, spec §10). Atomically sets the main * `status` to 'published' and — when the collection has per-locale status (M6) — every companion * row's `_status` to 'published', in a single transaction. For a non-localized / no-status * collection it is a plain publish of the single row. Only touches status columns (no field * values), so it needs none of the localized-write machinery. */ publishAllLocales(params: { collectionName: string; entryId: string; user?: UserContext; overrideAccess?: boolean; routeAuthorized?: boolean; authenticatedScope?: AuthenticatedScope; /** Who performed the publish, recorded on the events and the trail. */ actor?: RequestActor; }): Promise; /** * Whether this user may update the entry, decided without writing anything. * * The same load-then-check `updateEntry` performs, so it sees the * collection's stored per-document rules — owner-only and role-based — which * coarse RBAC does not express. For callers that write something OTHER than * the document and must still be held to the document's update rules; * version history is one. Sharing this path rather than restating the * decision elsewhere is what stops the gate drifting from the writer. */ canUpdateEntry(params: { collectionName: string; entryId: string; user?: UserContext; routeAuthorized?: boolean; /** * The caller's authenticated scope. A scoped API key is judged on its OWN * update grant, so the session super-admin bypass does not apply to a * super-admin-owned key when this gate authorizes a version-label edit. */ authenticatedScope?: AuthenticatedScope; }): Promise; /** * Additionally authorize a write that changes a document's published state. * * Publishing is an ordinary write that sets `status: "published"`, so the * `update`/`create` gate a path already ran does not distinguish it. A move * into published needs `publish`, a move out of it needs `unpublish`, and * both are ON TOP of the write permission — editing and publishing are * separate capabilities. A write that is not a transition returns `null` and * nothing extra is required. * * `collectionHasStatus` is the draft/published lifecycle flag * (`collection.status === true`), the same signal the read path filters on. It * gates this check because a collection WITHOUT the lifecycle can still carry * an ordinary user-defined field named `status`: setting that to "published" * is a field edit, not a publish, and must not demand `publish-`. * * `nextStatus` is the FINAL status the write will persist — read after the * before-hooks and field-write-access have run, not the raw request body — so * a hook that derives `status: "published"` cannot let a caller publish * without the permission. `previousStatus` is the main-row status, or, for a * write targeting a non-default locale, that locale's companion `_status`, * since a per-locale translation publishes through the companion row and not * the main row. */ private checkStatusTransitionAccess; /** * Resolve the caller's publish AND unpublish authorization on the pooled * connection, BEFORE a write transaction opens, so the transaction/batch write * paths can enforce a transition against the row-locked status without reading * permission storage inside the transaction (see {@link TransitionAuthorization}). * * Both ops are resolved because a batch is heterogeneous — one row may publish * while another unpublishes — and the per-row op is only known under the lock. * No-ops (returns all-allowed) for a trusted write or a collection with no * draft/published lifecycle. */ resolveTransitionAuthorization(args: { collectionName: string; accessUser?: UserContext; overrideAccess?: boolean; authenticatedScope?: AuthenticatedScope; executor?: unknown; }): Promise; /** * Enforce a pre-resolved {@link TransitionAuthorization} against the status read * UNDER the row lock, inside a caller-provided transaction. For an update it * locks the row (the write below takes the same lock anyway) and re-reads the * committed status, so a concurrent writer that changed the published state * between the pre-transaction read and this lock is accounted for; classifying * against that locked status, it returns the matching 403 if the transition is * denied, or `null` when the write is allowed. A create has no prior row, so * only a publish is possible and no lock/read is taken. * * Called immediately before the INSERT/UPDATE, so returning a denial leaves * nothing written for this row — no rollback needed. */ private enforceTransitionUnderLock; updateEntry(params: { collectionName: string; entryId: string; user?: UserContext; /** * Who performed the write, recorded on the outbox event. Set by the * transport; absent for internal writes, which record as `system`. */ actor?: RequestActor; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows. See {@link RelatedRowReadContext.trusted}. */ trusted?: (collection: string) => boolean; /** Write locale (i18n M5): translatable values are updated for this language only. */ locale?: string; routeAuthorized?: boolean; context?: Record; /** * Set when this write restores an earlier version, recording which one on * the version it captures. Lineage cannot be inferred afterwards: a * restore is an ordinary write that happens to reproduce an earlier state. */ sourceVersionNo?: number; authenticatedScope?: AuthenticatedScope; }, body: Record, depth?: number): Promise; /** * Delete an entry. * Applies collection-level access control and hooks. * * Security checks are applied in order: * 1. Collection-level access (AccessControlService) * * @param params - Collection name, entry ID, and optional user context * @returns Deletion result or error */ deleteEntry(params: { collectionName: string; entryId: string; user?: UserContext; /** Who performed the delete, recorded on the outbox event. */ actor?: RequestActor; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** When true, the route middleware already ran the RBAC gate; stored rules * are still enforced. See CollectionAccessService.checkCollectionAccess. */ routeAuthorized?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; /** * The caller's authenticated scope. A scoped API key is judged on its OWN * delete grant here, so the session super-admin bypass does not apply to a * super-admin-owned key on the delete gate. */ authenticatedScope?: AuthenticatedScope; }): Promise; /** * Create a new entry within an existing transaction. * * @param tx - Transaction context from adapter * @param params - Collection name and optional user context * @param body - Entry data to create * @returns Created entry or error * @throws Error if transaction operations fail * * @example * ```typescript * await adapter.transaction(async (tx) => { * const entry = await entryService.createEntryInTransaction(tx, params, data); * // Other operations in the same transaction... * }); * ``` */ createEntryInTransaction(tx: TransactionContext, params: { collectionName: string; user?: UserContext; overrideAccess?: boolean; routeAuthorized?: boolean; /** * Who performed the write. The bulk callers already spread this in; until * it was declared here it was received and dropped, so every event and * activity entry these paths recorded attributed an API-key write to the * key's OWNER as though a person had made it. */ actor?: RequestActor; transitionAuth?: TransitionAuthorization; }, body: Record): Promise>; /** * Update an entry within an existing transaction. * * @param tx - Transaction context from adapter * @param params - Collection name, entry ID, and optional user context * @param body - Update data * @returns Updated entry or error * @throws Error if transaction operations fail */ updateEntryInTransaction(tx: TransactionContext, params: { collectionName: string; entryId: string; user?: UserContext; overrideAccess?: boolean; routeAuthorized?: boolean; /** * Who performed the write. The bulk callers already spread this in; until * it was declared here it was received and dropped, so every event and * activity entry these paths recorded attributed an API-key write to the * key's OWNER as though a person had made it. */ actor?: RequestActor; transitionAuth?: TransitionAuthorization; }, body: Record): Promise>; /** * Delete an entry within an existing transaction. * * @param tx - Transaction context from adapter * @param params - Collection name, entry ID, and optional user context * @returns Deletion result or error * @throws Error if transaction operations fail */ deleteEntryInTransaction(tx: TransactionContext, params: { collectionName: string; entryId: string; user?: UserContext; /** Who performed the delete, recorded on the outbox event. */ actor?: RequestActor; }): Promise>; /** * Internal helper to create a single entry within a transaction. * * This is a streamlined version of createEntryInTransaction that: * - Skips collection-level access check (done once by caller) * - Optionally skips hooks for performance * - Returns the same result format * * @param tx - Transaction context * @param params - Collection name and optional user context * @param body - Entry data to create * @param skipHooks - Whether to skip hook execution * @returns CollectionServiceResult with created entry or error */ createSingleEntryInTransaction(tx: TransactionContext, params: { collectionName: string; user?: UserContext; overrideAccess?: boolean; routeAuthorized?: boolean; /** * Who performed the write. The bulk callers already spread this in; until * it was declared here it was received and dropped, so every event and * activity entry these paths recorded attributed an API-key write to the * key's OWNER as though a person had made it. */ actor?: RequestActor; transitionAuth?: TransitionAuthorization; }, body: Record, skipHooks: boolean): Promise>; /** * Internal helper to update a single entry within a transaction. * * This is a streamlined version of updateEntryInTransaction that: * - Skips collection-level access check (done once by caller) * - Optionally skips hooks for performance * - Returns the same result format * * @param tx - Transaction context * @param params - Collection name and optional user context * @param entryId - ID of the entry to update * @param body - Partial data to update * @param skipHooks - Whether to skip hook execution * @returns CollectionServiceResult with updated entry or error */ updateSingleEntryInTransaction(tx: TransactionContext, params: { collectionName: string; user?: UserContext; overrideAccess?: boolean; routeAuthorized?: boolean; /** * Who performed the write. The bulk callers already spread this in; until * it was declared here it was received and dropped, so every event and * activity entry these paths recorded attributed an API-key write to the * key's OWNER as though a person had made it. */ actor?: RequestActor; transitionAuth?: TransitionAuthorization; authenticatedScope?: AuthenticatedScope; }, entryId: string, body: Record, skipHooks: boolean): Promise>; /** * Internal helper to delete a single entry within a transaction. * * This is a streamlined version of deleteEntryInTransaction that: * - Skips collection-level access check (done once by caller) * - Optionally skips hooks for performance * - Returns the same result format * * @param tx - Transaction context * @param params - Collection name and optional user context * @param entryId - ID of the entry to delete * @param skipHooks - Whether to skip hook execution * @returns CollectionServiceResult with deletion status */ deleteSingleEntryInTransaction(tx: TransactionContext, params: { collectionName: string; user?: UserContext; overrideAccess?: boolean; /** Who performed the delete, recorded on the outbox event. */ actor?: RequestActor; }, entryId: string, skipHooks: boolean): Promise>; } /** * CollectionEntryService — Thin facade for collection entry CRUD operations. * * This file was originally a 6,490-line god file. It has been decomposed into * focused single-responsibility services: * * - {@link CollectionAccessService} — Access control evaluation (RBAC + collection rules) * - {@link CollectionHookService} — Hook context building and stored hook management * - {@link CollectionQueryService} — Read operations (list, count, get) * - {@link CollectionMutationService} — Write operations (create, update, delete) * - {@link CollectionBulkService} — Bulk and batch operations * * Utility functions live in `collection-utils.ts` and shared types in `collection-types.ts`. * * This facade preserves the original public API so that all callers (DI container, * API handlers, tests) continue to work unchanged. */ /** * CollectionEntryService handles all entry-level CRUD operations for dynamic collections. * * This is a thin facade that delegates to focused split services. The constructor * signature and public API are unchanged from the original implementation. * * @extends BaseService - Provides adapter access, transaction helpers */ declare class CollectionEntryService extends BaseService { /** * Offers a retention pass after a write — both of them: the webhook event * ledger and the audit trails, each on its own window and its own gate. The * runner decides which are configured, so a construction site that forwards * only one policy silently leaves that domain unpruned rather than failing. * * Wired here rather than at a caller because every write path that appends * an event runs through this service — the dispatcher-facing handler, * `CollectionService`, and direct callers alike — so this is the one place * that covers them all. */ private readonly retentionRunner?; /** * Kicks an immediate, bounded drain after a write (via Next `after()`), so * the first delivery attempt does not wait for the next scheduled trigger. * Wired at the same seam as `retentionRunner` for the same reason. */ private readonly fastDrainScheduler?; /** * Resolves the cache revalidator that flushes a write's revalidation intent * post-commit. Wired at the same seam as `fastDrainScheduler` because every * event-appending write runs through this service. A resolver (not the * instance) so it is read at flush time: this service is constructed during * boot, before a Next cache adapter registers, and an eager capture would * memoize the no-op default. Returns undefined when no adapter is present. */ private readonly resolveCacheRevalidator?; private readonly accessService; private readonly hookService; private readonly queryService; private readonly mutationService; private readonly bulkService; constructor(adapter: DrizzleAdapter, logger: Logger, fileManager: CollectionFileManager, collectionService: DynamicCollectionService, relationshipService: CollectionRelationshipService, hookRegistry: HookRegistry, accessControlService: AccessControlService, fieldGroupDataService?: FieldGroupDataService, rbacAccessControlService?: RBACAccessControlService, /** Normalized localization config (i18n M4) — forwarded to the query service. */ localization?: SanitizedLocalizationConfig, /** * Offers a retention pass after a write — both of them: the webhook event * ledger and the audit trails, each on its own window and its own gate. The * runner decides which are configured, so a construction site that forwards * only one policy silently leaves that domain unpruned rather than failing. * * Wired here rather than at a caller because every write path that appends * an event runs through this service — the dispatcher-facing handler, * `CollectionService`, and direct callers alike — so this is the one place * that covers them all. */ retentionRunner?: RetentionRunner | undefined, /** * Kicks an immediate, bounded drain after a write (via Next `after()`), so * the first delivery attempt does not wait for the next scheduled trigger. * Wired at the same seam as `retentionRunner` for the same reason. */ fastDrainScheduler?: WebhookFastDrainScheduler | undefined, /** * Resolves the cache revalidator that flushes a write's revalidation intent * post-commit. Wired at the same seam as `fastDrainScheduler` because every * event-appending write runs through this service. A resolver (not the * instance) so it is read at flush time: this service is constructed during * boot, before a Next cache adapter registers, and an eager capture would * memoize the no-op default. Returns undefined when no adapter is present. */ resolveCacheRevalidator?: (() => CacheRevalidator | undefined) | undefined); listEntries(params: { collectionName: string; user?: UserContext; search?: string; page?: number; limit?: number; depth?: number; select?: Record; where?: WhereFilter; richTextFormat?: RichTextOutputFormat; sort?: string; /** Draft/Published lifecycle scope; forwarded to the query service. */ status?: "published" | "draft" | "all"; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** Requested content locale (i18n M4) — forwarded to the query service. */ locale?: string; /** Fallback control (`false`/`"none"` disables fallback). */ fallbackLocale?: string | false; context?: Record; /** Route authorization already ran the coarse RBAC gate; stored rules run. */ routeAuthorized?: boolean; /** Caller's authenticated scope; a scoped key is judged on its read grant. */ authenticatedScope?: AuthenticatedScope; }): Promise>>; countEntries(params: { collectionName: string; user?: UserContext; search?: string; where?: WhereFilter; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** Requested content locale (i18n M4) — forwarded to the query service. */ locale?: string; /** Fallback control (`false`/`"none"` disables fallback). */ fallbackLocale?: string | false; context?: Record; /** Route authorization already ran the coarse RBAC gate; stored rules run. */ routeAuthorized?: boolean; /** Caller's authenticated scope; a scoped key is judged on its read grant. */ authenticatedScope?: AuthenticatedScope; }): Promise>; getEntry(params: { collectionName: string; entryId: string; user?: UserContext; depth?: number; select?: Record; richTextFormat?: RichTextOutputFormat; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * Draft/Published filter override (only effective when collection.status * === true). 'all' bypasses the default published-only filter — used by * the admin so unpublished entries stay reachable. Forwarded to the * query service which maps it to a SQL predicate. */ status?: "published" | "draft" | "all"; /** * Opt in to the working-draft overlay (draft/published split): a trusted * editor read returns the pending working draft in place of the live row. * Forwarded to the query service, which gates it on update trust. */ includeWorkingDraft?: boolean; /** Requested content locale (i18n M4) — forwarded to the query service. */ locale?: string; /** Fallback control (`false`/`"none"` disables fallback). */ fallbackLocale?: string | false; context?: Record; /** Route authorization already ran the coarse RBAC gate; stored rules run. */ routeAuthorized?: boolean; /** Caller's authenticated scope; a scoped key is judged on its read grant. */ authenticatedScope?: AuthenticatedScope; }): Promise>; /** * Batches a write-triggered pass may run. Small on purpose: the write path is * the only retention trigger an install without a drain has, so the pass must * be awaited to survive a serverless invocation being frozen after the * response — which means one save per interval pays for it, and that save * should not be waiting on a full backlog sweep. Ten thousand rows an hour * from this path alone keeps ahead of most sites; anything with a drain gets * the full budget there. */ private static readonly WRITE_PATH_PRUNE_BATCHES; /** * Run a retention pass after a successful write, if one is due. * * Awaited rather than fired and forgotten: on a serverless runtime the * invocation can be frozen or torn down as soon as the response is returned, * so a detached promise may never get past the gate — and for an install with * no drain this is the only trigger there is. `maybeRun` absorbs its own * failures, so this cannot turn a successful save into an error. */ private offerRetentionPass; /** * Run the post-write side effects only when the mutation actually recorded a * change (and therefore appended an outbox event). A rejected write — a * validation or access failure surfaced as `success: false`, or a bulk/batch * operation where every item failed — recorded nothing, so kicking the drain * would deliver unrelated pending events for a write that changed nothing (and * let a failed, possibly unauthorized attempt trigger outbound webhooks). The * three result shapes report "recorded something" differently. `success` is * not a reliable proxy in either direction: a create/update/delete can commit * the event and then return `success: false` when a post-commit hook throws, * and a `publishAllLocales` no-op (or a no-op update) returns `success: true` * having recorded nothing. So a single write keys off the explicit * `eventRecorded` flag, which every event-writing path sets. Bulk/batch results * carry the same flag for their committed-but-hook-failed items on top of the * success count. */ private afterWriteIfRecorded; /** * Whether a mutation result represents at least one committed content write. * A single create/update/delete carries the explicit `committed` flag (set the * moment its transaction commits, independent of the recording and revalidation * opt-outs), so even a write that opts out of BOTH — no event, no intent — is * covered, while a rejected request (validation / access / not-found) and a * `publishAllLocales` no-op are not. Bulk/batch results use their positive * counts; `eventRecorded` covers a committed-but-hook-failed batch. NOT keyed * off `success`, which a no-op update also reports. */ private static hasCommittedWrite; /** * Flush a committed write's cache-revalidation intents through the registered * revalidator (a no-op when no cache adapter is present). Runs on the same * gate as the drain — a write that recorded nothing revalidates nothing — and * absorbs its own failure so it never turns a committed write into an error. * Awaited (like the retention pass) so an async revalidator's work is not left * detached, where a serverless response could cut it off before it completes. */ private flushRevalidation; /** * Flush an explicit set of revalidation intents collected by a caller-owned * transaction (for example the `CollectionService` transaction wrappers, whose * return values carry only the entry). Shares the automatic post-write path: * a no-op when no cache adapter is registered, and self-absorbing on error so * a revalidator fault never turns a committed write into a failure. */ /** * Run the write-path maintenance the automatic paths run, for the * `CollectionService.withTransaction` wrapper to call after a tx-API write * commits. The wrappers return only the entry, so — like * `flushRevalidationIntents` — these cannot be triggered from the wrapper's own * result. Mirrors `afterWriteIfRecorded`: a committed write offers the * opportunistic retention pass (the write path is the only prune trigger for an * install with no drain, so tx-API writes must offer it too, or `nextly_events` * grows unbounded), and a recorded event schedules the fast drain. No-ops when * the respective runner/scheduler is unwired. */ offerPostCommitTxMaintenance(opts: { committedWrite: boolean; recordedEvent: boolean; }): Promise; flushRevalidationIntents(intents: RevalidationIntent[]): Promise; createEntry(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; user?: UserContext; /** Who performed the write, recorded on the outbox event. */ actor?: RequestActor; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** Write locale (i18n M5) — translatable values stored for this language. */ locale?: string; context?: Record; /** * The caller's authenticated scope. For a scoped API-key REST create the * publish transition gate (create-as-published) judges the key's OWN grants. */ authenticatedScope?: AuthenticatedScope; }, body: Record, depth?: number): Promise>; /** * Whether this user may update the entry, without performing the update. * * For callers that write something other than the document and still owe it * the document's own update rules. See the mutation service for why this * shares `updateEntry`'s evaluation rather than restating it. */ canUpdateEntry(params: { collectionName: string; entryId: string; user?: UserContext; routeAuthorized?: boolean; /** API-key scope; judges the update gate on the key's own grant. */ authenticatedScope?: AuthenticatedScope; }): Promise; updateEntry(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; entryId: string; user?: UserContext; /** Who performed the write, recorded on the outbox event. */ actor?: RequestActor; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** Write locale (i18n M5) — translatable values updated for this language. */ locale?: string; context?: Record; /** * Set when this write restores an earlier version, recorded on the * version it captures. */ sourceVersionNo?: number; /** * The caller's authenticated scope. For a scoped API-key REST write the * publish/unpublish transition gate judges the key's OWN grants. */ authenticatedScope?: AuthenticatedScope; }, body: Record, depth?: number): Promise>; /** i18n M7: publish every language of an entry at once (spec §10). */ publishAllLocales(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; entryId: string; user?: UserContext; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * Set by the REST dispatcher: the route already authorized this POST as * `update`, so the preliminary update gate skips its redundant RBAC re-check. */ routeAuthorized?: boolean; /** API-key scope; gates the unconditional publish check. */ authenticatedScope?: AuthenticatedScope; }): Promise>; deleteEntry(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; entryId: string; user?: UserContext; /** Who performed the delete, recorded on the outbox event. */ actor?: RequestActor; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; routeAuthorized?: boolean; context?: Record; /** API-key scope; judges the delete gate on the key's own grant. */ authenticatedScope?: AuthenticatedScope; }): Promise>; /** * Resolve this collection's localized companion verdicts on the pooled connection. * * Call it BEFORE opening a transaction whose body uses the `*InTransaction` methods below. * Those cannot do it themselves: resolving issues a query, a query against a missing relation * aborts the whole transaction on PostgreSQL, and a pooled probe taken while a transaction is * open waits for a connection that transaction will not release. * * Skipping it throws nothing. The write commits and its durable version snapshot and outbound * event silently omit every localized component value. */ warmLocalizedReadiness(collectionName: string): Promise; /** * Remove a document's pending working-draft sidecar under the same parent-row * lock a draft save takes, so a discard cannot delete a draft that a * concurrent save committed after the discard's checks. The discard handler * has already authorized read and update on the document. */ discardWorkingDraft(params: { collectionName: string; entryId: string; }): Promise; createEntryInTransaction(tx: TransactionContext, params: Parameters[1], body: Record): Promise>; updateEntryInTransaction(tx: TransactionContext, params: Parameters[1], body: Record): Promise>; deleteEntryInTransaction(tx: TransactionContext, params: { collectionName: string; entryId: string; user?: UserContext; /** Who performed the delete, recorded on the outbox event. */ actor?: RequestActor; }): Promise>; duplicateEntry(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; entryId: string; user?: UserContext; overrides?: Record; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; context?: Record; /** Acting identity from the transport, forwarded to the recorded event. */ actor?: RequestActor; /** API-key scope; judges the create-as-published on the key's own grant. */ authenticatedScope?: AuthenticatedScope; }): Promise>; bulkDeleteEntries(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; ids: string[]; user?: UserContext; /** Who performed the delete, recorded on each entry's outbox event. */ actor?: RequestActor; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; routeAuthorized?: boolean; context?: Record; /** API-key scope; judges each per-id delete on the key's own grant. */ authenticatedScope?: AuthenticatedScope; }): Promise>; bulkUpdateEntries(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; ids: string[]; data: Record; user?: UserContext; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; routeAuthorized?: boolean; context?: Record; /** Acting identity from the transport, forwarded to the recorded event. */ actor?: RequestActor; /** API-key scope; judges each per-id transition on the key's own grant. */ authenticatedScope?: AuthenticatedScope; }): Promise>>; bulkUpdateByQuery(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; where: WhereFilter; data: Record; user?: UserContext; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** Route auth already ran; response is still redacted for this user */ routeAuthorized?: boolean; context?: Record; /** Acting identity from the transport, forwarded to the recorded event. */ actor?: RequestActor; /** API-key scope; judges the collection gate + transitions on the key's own grant. */ authenticatedScope?: AuthenticatedScope; }, options?: BulkOperationOptions & { limit?: number; }): Promise>>; bulkDeleteByQuery(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; where: WhereFilter; user?: UserContext; /** Who performed the delete, recorded on each entry's outbox event. */ actor?: RequestActor; /** Caller's authenticated scope; a scoped key is judged on its own grant. */ authenticatedScope?: AuthenticatedScope; routeAuthorized?: boolean; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; context?: Record; }, options?: { limit?: number; }): Promise>; createEntries(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; user?: UserContext; overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; authenticatedScope?: AuthenticatedScope; }, entries: Record[], options?: BulkOperationOptions): Promise; createEntriesInTransaction(tx: TransactionContext, params: { collectionName: string; user?: UserContext; authenticatedScope?: AuthenticatedScope; }, entries: Record[], options?: BulkOperationOptions): Promise; updateEntries(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; user?: UserContext; authenticatedScope?: AuthenticatedScope; }, entries: BulkUpdateEntry[], options?: BulkOperationOptions): Promise; updateEntriesInTransaction(tx: TransactionContext, params: { collectionName: string; user?: UserContext; authenticatedScope?: AuthenticatedScope; }, entries: BulkUpdateEntry[], options?: BulkOperationOptions): Promise; deleteEntries(params: { collectionName: string; /** * Skip cache revalidation for this write (the outbox drain still runs). * Set by callers that own their cache strategy — a CLI, seed, or * bulk-import write — so it does not fan out a revalidation per row. */ disableRevalidate?: boolean; user?: UserContext; /** Who performed the delete, recorded on each entry's outbox event. */ actor?: RequestActor; }, ids: string[], options?: BulkOperationOptions): Promise; deleteEntriesInTransaction(tx: TransactionContext, params: { collectionName: string; user?: UserContext; /** Who performed the delete, recorded on each entry's outbox event. */ actor?: RequestActor; }, ids: string[], options?: BulkOperationOptions): Promise; } /** Result shape returned by metadata service methods. */ interface MetadataServiceResult { success: boolean; statusCode: number; message: string; data: Record | Record[] | null; meta?: Record; /** * The failure's typed fields, so a boundary can rebuild the exact error * rather than guess it from the status. Absent on success and on an untyped * failure. */ code?: string; messageKey?: string; publicData?: unknown; } /** * CollectionMetadataService handles all collection-level CRUD operations. * * Responsibilities: * - Create new collections (schema generation, migration, registration) * - List collections with pagination and search * - Get single collection details * - Update collection metadata and schema * - Delete collections * * Uses the database adapter pattern for multi-database support (PostgreSQL, MySQL, SQLite). * Delegates actual database operations to DynamicCollectionService. * * @extends BaseService - Provides adapter access and transaction helpers * * @example * ```typescript * const metadataService = new CollectionMetadataService( * adapter, logger, fileManager, collectionService * ); * const result = await metadataService.createCollection({ * name: 'posts', * label: 'Posts', * fields: [...] * }); * ``` */ declare class CollectionMetadataService extends BaseService { private readonly fileManager; private readonly collectionService; private permissionSeedService?; constructor(adapter: DrizzleAdapter, logger: Logger, fileManager: CollectionFileManager, collectionService: DynamicCollectionService); /** * Set the PermissionSeedService for auto-seeding permissions on collection changes. * Called from DI registration after both services are constructed. */ setPermissionSeedService(service: PermissionSeedService): void; /** * Seed CRUD permissions for a collection and assign to super_admin. * Non-blocking — errors are logged but do not fail the parent operation. */ private seedPermissionsForCollection; /** * Register a runtime-generated Drizzle schema in the adapter's table resolver * so the table is immediately usable without server restart. */ private registerRuntimeSchema; /** * Check if running in development mode. */ private isDevelopment; /** * Register dynamic schemas with the file manager. * * @param schemas - Map of schema names to schema objects */ registerDynamicSchemas(schemas: Record): void; /** Drop the cached Drizzle schema for one slug so the next load rebuilds it. */ invalidateSchemaForSlug(collectionName: string): void; /** * Create a new collection. * Generates schema, migration files, and registers the collection. * * @param data - Collection creation data * @returns Service result with created collection or error */ createCollection(data: { name: string; label: string; description?: string; icon?: string; group?: string; useAsTitle?: string; hidden?: boolean; order?: number; sidebarGroup?: string; /** Whether the collection has the Draft/Published status feature enabled. */ status?: boolean; /** i18n: whether the collection is localized (translatable fields + companion table). */ localized?: boolean; /** Whether every save is recorded as a restorable version. */ versions?: boolean; /** Whether writes bust cache tags. Default on; false opts out entirely. */ revalidate?: boolean; /** * Whether writes are recorded to the webhook outbox. Default on; false * keeps this collection's content out of the outbox and every delivery. */ webhooks?: boolean; fields: FieldDefinition[]; hooks?: Record[]; createdBy?: string; }): Promise; /** * List collections with pagination, search, and sorting. * * Includes schema by default since the UI needs field counts for display. * Consumers can set includeSchema: false for API-only use cases where * field details are not needed. * * @param options - Pagination, search, and sort options * @returns Paginated list of collections */ listCollections(options?: { page?: number; limit?: number; search?: string; sortBy?: "name" | "slug" | "createdAt" | "updatedAt"; sortOrder?: "asc" | "desc"; includeSchema?: boolean; }): Promise; /** * Get a single collection by name. * * @param params - Parameters containing collection name * @returns Collection details or error */ getCollection(params: { collectionName: string; }): Promise; /** * Update a collection's metadata and/or schema. * * @param params - Parameters containing collection name * @param body - Update data (label, description, icon, fields) * @returns Updated collection or error */ updateCollection(params: { collectionName: string; }, body: { label?: string; description?: string; icon?: string; group?: string; useAsTitle?: string; hidden?: boolean; order?: number; sidebarGroup?: string; /** Toggle Draft/Published. Honoured when defined; undefined leaves it unchanged. */ status?: boolean; /** i18n: toggle Internationalization. Honoured when defined; undefined leaves it unchanged. */ localized?: boolean; /** Toggle version history. Honoured when defined; undefined leaves it unchanged. */ versions?: boolean; /** Toggle cache revalidation. Honoured when defined; undefined leaves it unchanged. */ revalidate?: boolean; /** Toggle webhook recording. Honoured when defined; undefined leaves it unchanged. */ webhooks?: boolean; fields?: FieldDefinition[]; hooks?: Record[]; }): Promise; /** * Delete a collection. * Generates drop migration, deletes schema file, and unregisters the collection. * * @param params - Parameters containing collection name * @returns Deletion result */ deleteCollection(params: { collectionName: string; }): Promise; } /** * CollectionService - Unified service for collection operations * * This service provides a clean API for both collection metadata (CRUD on collections) * and entry operations (CRUD on documents within collections). It follows the new * service layer architecture with: * * - Exception-based error handling using NextlyError * - RequestContext for user/locale context * - PaginatedResult for list operations * - Transaction-aware methods (*InTransaction) using adapter transactions * - Database adapter abstraction for multi-DB support (PostgreSQL, MySQL, SQLite) * * Internally delegates to CollectionMetadataService and CollectionEntryService * for the actual implementation, converting their return format to the new pattern. * * @example * ```typescript * import { CollectionService, NextlyError } from 'nextly'; * * const service = new CollectionService(adapter, logger, metadataService, entryService); * * // Create a collection * const collection = await service.createCollection({ * name: 'posts', * label: 'Blog Posts', * fields: [...] * }, context); * * // Create an entry * const entry = await service.createEntry('posts', { title: 'Hello' }, context); * * // Error handling * try { * const entry = await service.findEntryById('posts', 'nonexistent', context); * } catch (error) { * if (NextlyError.is(error)) { * console.log(error.code); // 'NOT_FOUND' * console.log(error.statusCode); // 404 * } * } * * // Transaction support * await service.withTransaction(async (tx) => { * const entry = await service.createEntryInTransaction(tx, 'posts', data, context); * await service.updateEntryInTransaction(tx, 'posts', entry.id, moreData, context); * }); * ``` */ /** * Collection metadata returned from operations */ interface Collection { id: string; name: string; label: string; tableName: string; description?: string; icon?: string; schemaDefinition: { fields: FieldDefinition[]; }; createdBy?: string; createdAt: Date; updatedAt: Date; } /** * Input for creating a collection */ interface CreateCollectionInput { name: string; label: string; description?: string; icon?: string; /** Whether the collection has Draft/Published enabled. */ status?: boolean; /** i18n: whether the collection is localized (translatable fields + companion table). */ localized?: boolean; fields: FieldDefinition[]; } /** * Input for updating a collection */ interface UpdateCollectionInput { label?: string; description?: string; icon?: string; fields?: FieldDefinition[]; } /** * Options for listing collections */ interface ListCollectionsOptions { page?: number; limit?: number; search?: string; sortBy?: "slug" | "createdAt" | "updatedAt"; sortOrder?: "asc" | "desc"; includeSchema?: boolean; } /** * Entry (document) within a collection */ interface CollectionEntry { id: string; [key: string]: unknown; createdAt: Date; updatedAt: Date; } /** * CollectionService - Unified service for collection and entry operations * * Provides both collection metadata CRUD (create/update/delete collections) * and entry CRUD (documents within collections) with: * * - Exception-based error handling (throws NextlyError) * - Type-safe RequestContext * - PaginatedResult for list operations * - Database adapter abstraction for multi-DB support * - Transaction support via adapter transactions * * @extends BaseService - Provides adapter access, transaction helpers, and WHERE clause builders */ declare class CollectionService extends BaseService { private readonly metadataService; private readonly entryService; constructor(adapter: DrizzleAdapter, logger: Logger, metadataService: CollectionMetadataService, entryService: CollectionEntryService); /** * Revalidation intents produced by createEntryInTransaction / * updateEntryInTransaction / deleteEntryInTransaction calls made during an * owned transaction, keyed by the transaction handle so concurrent * transactions (one pooled client each on Postgres/MySQL) never share a * collector. The wrappers push into it; `withTransaction` drains it once the * transaction commits. */ private readonly pendingTxIntents; /** * Transaction handles whose `*InTransaction` wrappers recorded at least one * outbox event, so `withTransaction` can offer the fast drain once after the * owning transaction commits — the tx-API wrappers return only the entry, so * the event signal (like the revalidation intent) has nowhere else to go. */ private readonly pendingTxEvents; /** * Transaction handles whose `*InTransaction` wrappers committed at least one * write, independent of whether that write produced a revalidation intent or an * outbox event. `withTransaction` offers the opportunistic retention pass for * any committed write (matching the automatic path), so a write that opts out * of BOTH revalidation and recording still triggers the pass rather than * relying on those optional signals. */ private readonly pendingTxCommittedWrites; /** * Run work inside a database transaction, then flush the cache-revalidation * intents produced by any createEntryInTransaction / updateEntryInTransaction / * deleteEntryInTransaction calls made against the same `tx`. The flush happens * after the transaction commits, so a rolled-back write busts nothing. Because * the wrappers return only the entry (or void), this is the supported way to * coordinate atomic multi-writes with correct revalidation — without it the * committed writes' intents would have nowhere to go. * * Unlike the base helper, this yields the adapter's `TransactionContext` (the * handle the *InTransaction wrappers expect), not a raw driver transaction. * * Warm each collection's localized readiness before opening the transaction — see * {@link warmLocalizedReadiness}. It cannot be done from inside one, and without it the writes * commit with their version snapshots and outbound events missing every localized component * value. * * @example * ```typescript * await service.warmLocalizedReadiness('posts'); * await service.withTransaction(async (tx) => { * const entry = await service.createEntryInTransaction(tx, 'posts', data, context); * await service.updateEntryInTransaction(tx, 'posts', entry.id, moreData, context); * }); * ``` */ withTransaction(work: (tx: TransactionContext) => Promise): Promise; /** * Record a wrapper's revalidation intent against the active owned transaction, * if one is in progress, so `withTransaction` can flush it after commit. A * no-op when the caller obtained `tx` some other way (there is nowhere to defer * the flush to); such callers should use the lower-level * `CollectionEntryService.*InTransaction`, whose results carry the intent. */ private collectTxIntent; /** * Record that a wrapper's `*InTransaction` write appended an outbox event * against the active owned transaction, so `withTransaction` offers the fast * drain once after commit. Only tracked when a collector exists for this `tx` * (a `withTransaction` frame owns it); a caller that obtained `tx` some other * way owns the drain, exactly as it owns the revalidation flush. */ private collectTxEvent; /** * Record that a wrapper's `*InTransaction` write succeeded against the active * owned transaction, so `withTransaction` offers retention for the committed * write even when it produced no intent and no event. A failed wrapper throws * (rolling the transaction back), so only committed writes reach here. */ private collectTxCommittedWrite; /** * Register dynamic collection schemas for runtime use. * * This should be called during app initialization to register * the generated Drizzle schema files for dynamic collections. * * @param schemas - Object mapping schema names to Drizzle table definitions * * @example * ```typescript * import * as dynamicSchemas from "@/db/schemas/dynamic"; * * const service = getCollectionsService(); * service.registerDynamicSchemas(dynamicSchemas); * ``` */ registerDynamicSchemas(schemas: Record): void; /** Drop the cached Drizzle schema for one slug so the next load rebuilds it. */ invalidateSchemaForSlug(collectionName: string): void; /** * Create a new collection * * @param input - Collection creation data * @param context - Request context with user info * @returns Created collection * @throws NextlyError if creation fails * * @example * ```typescript * const collection = await service.createCollection({ * name: 'posts', * label: 'Blog Posts', * fields: [ * { name: 'title', type: 'text', required: true }, * { name: 'content', type: 'richText' }, * ] * }, context); * ``` */ createCollection(input: CreateCollectionInput, context: RequestContext$2): Promise; /** * List collections with pagination * * @param options - Pagination and filter options * @param context - Request context * @returns Paginated list of collections * @throws NextlyError if listing fails */ listCollections(options: ListCollectionsOptions | undefined, _context: RequestContext$2): Promise>; /** * Get a single collection by name * * @param collectionName - Name of the collection * @param context - Request context * @returns Collection metadata * @throws NextlyError with NOT_FOUND if collection doesn't exist */ getCollection(collectionName: string, _context: RequestContext$2): Promise; /** * Update a collection's metadata and/or schema * * @param collectionName - Name of the collection to update * @param input - Update data * @param context - Request context * @returns Updated collection * @throws NextlyError if update fails */ updateCollection(collectionName: string, input: UpdateCollectionInput, _context: RequestContext$2): Promise; /** * Delete a collection * * @param collectionName - Name of the collection to delete * @param context - Request context * @throws NextlyError if deletion fails */ deleteCollection(collectionName: string, _context: RequestContext$2): Promise; /** * Create a new entry in a collection * * @param collectionName - Name of the collection * @param data - Entry data * @param context - Request context with user info * @returns Created entry * @throws NextlyError if creation fails * * @example * ```typescript * const post = await service.createEntry('posts', { * title: 'Hello World', * content: 'My first post', * }, context); * ``` */ createEntry(collectionName: string, data: Record, context: RequestContext$2): Promise; /** * Bulk-create entries in a single transaction (D56). * * Returns an index-based {@link BatchOperationResult} — the natural shape for * creates, which have no caller-supplied ids to key failures by (mirrors the * existing batch ops `createEntries`/`updateEntries`/`deleteEntries`). * * @param collectionName - Name of the collection * @param data - Array of entry payloads to create * @param context - Request context (carries `overrideAccess` for elevation) * @returns Per-batch result: counts, created ids, and index-keyed failures */ createMany(collectionName: string, data: Record[], context: RequestContext$2): Promise; /** * List entries in a collection * * @param collectionName - Name of the collection * @param options - Query options (pagination, sort, where) * @param context - Request context * @returns Paginated list of entries * @throws NextlyError if listing fails */ listEntries(collectionName: string, options: QueryOptions | undefined, context: RequestContext$2): Promise>; /** * Count entries matching an optional filter (D56 aggregation-lite). * * Lets plugins answer "how many?" without loading rows or dropping to raw * `ctx.db`. For richer aggregations (sum/avg/group-by) use the raw `ctx.db` * escape hatch (D33) — relational aggregation pipelines are out of scope. * * @param collectionName - Name of the collection * @param options - Optional `where` filter and full-text `search` * @param context - Request context (carries `overrideAccess` for elevation) * @returns Number of matching entries * @throws NextlyError if counting fails */ count(collectionName: string, options: { where?: Record; search?: string; } | undefined, context: RequestContext$2): Promise; /** * Find an entry by ID * * @param collectionName - Name of the collection * @param entryId - ID of the entry * @param context - Request context * @returns Entry data * @throws NextlyError with NOT_FOUND if entry doesn't exist */ findEntryById(collectionName: string, entryId: string, context: RequestContext$2): Promise; /** * Update an entry * * @param collectionName - Name of the collection * @param entryId - ID of the entry to update * @param data - Update data * @param context - Request context * @returns Updated entry * @throws NextlyError if update fails */ updateEntry(collectionName: string, entryId: string, data: Record, context: RequestContext$2): Promise; /** * Delete an entry * * @param collectionName - Name of the collection * @param entryId - ID of the entry to delete * @param context - Request context * @throws NextlyError if deletion fails */ deleteEntry(collectionName: string, entryId: string, context: RequestContext$2): Promise; /** * Resolve this collection's localized companion verdicts on the pooled connection. * * Run it BEFORE opening a transaction that uses the `*InTransaction` methods below. They cannot * do it for themselves: resolving a verdict issues a query, a query against a missing relation * aborts the whole transaction on PostgreSQL, and a pooled probe taken while a transaction is * open waits for a connection that transaction will not release until it ends. * * Nothing throws when it is skipped, which is the reason it is worth calling. An unresolved * verdict reads as unusable, so the writes commit normally while their durable version snapshots * and outbound events quietly omit every localized component value — an omission that surfaces * from a consumer of the event, by which point the snapshot is the historical record. * * Read-only and idempotent. Safe for a collection that is not localized. */ warmLocalizedReadiness(collectionName: string): Promise; /** * Create an entry within an existing transaction * * Use this when you need to coordinate multiple operations atomically. * * @param tx - Transaction context from adapter * @param collectionName - Name of the collection * @param data - Entry data * @param context - Request context * @returns Created entry * @throws Error if underlying service doesn't support transaction context * * Preceded by {@link warmLocalizedReadiness} for this collection, as in the example. * * @example * ```typescript * await service.warmLocalizedReadiness('posts'); * await service.withTransaction(async (tx) => { * const entry = await service.createEntryInTransaction(tx, 'posts', data, context); * await service.updateEntryInTransaction(tx, 'posts', entry.id, moreData, context); * }); * ``` */ createEntryInTransaction(tx: TransactionContext, collectionName: string, data: Record, context: RequestContext$2): Promise; /** * Update an entry within an existing transaction * * Preceded by {@link warmLocalizedReadiness} for this collection. This method runs entirely on * the caller's transaction, where a companion verdict can only be read and never resolved, and * an unresolved one reads as unusable — so without the warm-up the update commits while its * previous/post version snapshots and its outbound event omit every localized component value. * * @param tx - Transaction context from adapter * @param collectionName - Name of the collection * @param entryId - ID of the entry to update * @param data - Update data * @returns Updated entry * @throws Error if underlying service doesn't support transaction context */ updateEntryInTransaction(tx: TransactionContext, collectionName: string, entryId: string, data: Record, context: RequestContext$2): Promise; /** * Delete an entry within an existing transaction * * @param tx - Transaction context from adapter * @param collectionName - Name of the collection * @param entryId - ID of the entry to delete * @param context - Request context * @throws Error if underlying service doesn't support transaction context */ deleteEntryInTransaction(tx: TransactionContext, collectionName: string, entryId: string, context: RequestContext$2, /** * Acting identity, forwarded to the recorded `entry.deleted` event so an * API-key delete through this wrapper keeps key attribution. `RequestContext` * carries only `user`, so the actor is passed alongside it. */ actor?: RequestActor): Promise; /** * Translate a legacy CollectionServiceResult / MetadataServiceResult failure * into a thrown NextlyError. Only used for non-404/403 cases — those have * dedicated factory calls inline at each call site so identifiers can move * cleanly to logContext. * * Per §13.8, the public message is generic for the matched factory; the * inner legacy message moves to logContext for operators only and never * reaches the wire. */ /** * Rebuild the error a failed service envelope came from. * * Delegates to the shared converter so a plugin calling * `ctx.services.collections` is handed the same error a REST or Direct API * caller would get. This kept its own status table, which sent anything * outside 400/401/403/404/409 to an internal error -- so a hook throwing * `rateLimited()` reached a plugin as a 500 -- and whose input type omitted * `code`, `errors` and `publicData` entirely, so a validation failure also * arrived without its per-field issues. */ private mapLegacyErrorToNextlyError; } /** * CollectionsHandler - Unified facade for collection operations. * * This handler provides a backward-compatible API that delegates to specialized services: * - CollectionMetadataService: Collection CRUD (create, list, get, update, delete) * - CollectionEntryService: Entry CRUD with hooks and permissions * - CollectionRelationshipService: Relationship expansion and junction table management * * For new code, consider using the specialized services directly for better separation of concerns. * * @example * ```typescript * // Using the facade (backward compatible) * const handler = new CollectionsHandler(db); * await handler.createCollection({ name: 'posts', ... }); * * // Using specialized services directly (recommended for new code) * const metadataService = new CollectionMetadataService(db, fileManager, collectionService); * await metadataService.createCollection({ name: 'posts', ... }); * ``` */ declare class CollectionsHandler { /** Normalized localization config (i18n M4) — enables companion-aware reads. */ private readonly localization?; private readonly metadataService; private readonly entryService; private readonly relationshipService; private readonly collectionService; private readonly fileManager; private readonly logger; constructor(adapter: DrizzleAdapter, db: DatabaseInstance, logger?: Logger, consumerAppRoot?: string, /** Normalized localization config (i18n M4) — enables companion-aware reads. */ localization?: SanitizedLocalizationConfig | undefined, /** * Resolved webhook retention policy. Content writes offer to run a pass so * the event ledger stays bounded in installs that never configure a webhook * and therefore never run the drain. Null or absent leaves the event ledger * unpruned; it no longer decides whether a runner exists at all, since the * audit policy below can call for one on its own. */ webhookRetention?: ResolvedWebhookRetentionConfig | null, /** * Resolved audit-trail retention windows, forwarded for the same reason and * needed here in particular: this is the seam a dispatcher-driven install * writes through, so a policy that does not reach it is a trail that * install never prunes. Absent means the trails are kept in full. */ auditRetention?: ResolvedAuditRetentionConfig, /** * Resolved delivery-log retention, forwarded for the same reason and most * consequential here of the three. * * `email_deliveries` was previously swept only by the SEND path, on the * reasoning that sends are what make it grow. True while an install is * sending, and useless the moment it stops: the last rows written are the * newest, and nothing ever offers another pass to remove them. They then * sit indefinitely — recipient digests, under a setting that reads as a * bounded window. Content writes continue after the final send, which is * exactly the property the send path lacks. */ emailRetention?: ResolvedEmailRetentionConfig); /** * Ensure params have a `user` object for hook contexts. * * The API dispatcher passes `userId` (from the authenticated session) but * the entry service expects `user: { id }`. This bridges the gap so that * activity-log hooks receive a valid user and are not silently skipped. * * `routeAuthorized: true` marks that the route middleware * (`requireCollectionAccess`) already performed the coarse RBAC / code-access * gate, so the entry service skips re-running only THAT check. It is NOT a * trusted-server context: `overrideAccess` stays `false` so the stored * collection access rules (owner-only / role-based / authenticated / custom) * and field-level write access are still enforced with the real user — the * route pre-check authorizes the operation, not access to every record or * field. Trusted-server bypass is a separate, explicit `overrideAccess: true` * (seeds, plugin `as:'system'`), never inferred from route auth. */ /** * Whether this user may update the entry, without performing the update. * * Routed through the handler for the same reason the version read gate is: * this is the instance that actually serves collection writes, so a decision * taken here is the decision the write would take. */ canUpdateEntry(params: { collectionName: string; entryId: string; user?: UserContext; routeAuthorized?: boolean; /** API-key scope; judges the update gate on the key's own grant. */ authenticatedScope?: AuthenticatedScope; }): Promise; private resolveUserParam; /** * Wire the PermissionSeedService into the internal CollectionMetadataService. * Must be called after construction so that collection creation auto-seeds * CRUD permissions for newly created collections. */ setPermissionSeedService(service: PermissionSeedService): void; refreshCollectionSchema(tableName: string, freshTable: unknown): void; /** * Register dynamic schemas with the file manager. * @param schemas - Map of schema names to schema objects */ registerDynamicSchemas(schemas: Record): void; /** * Create a new collection. * @param data - Collection creation data */ createCollection(data: { name: string; label: string; description?: string; icon?: string; group?: string; order?: number; sidebarGroup?: string; /** Whether the collection has Draft/Published enabled. */ status?: boolean; /** i18n: whether the collection is localized (translatable fields + companion table). */ localized?: boolean; /** Whether writes bust cache tags. Default on; false opts the collection out. */ revalidate?: boolean; /** * Whether writes are recorded to the webhook outbox. Default on; false * keeps this collection's content out of the outbox and every delivery. */ webhooks?: boolean; fields: FieldDefinition[]; createdBy?: string; }): Promise; /** * List collections with pagination, search, and sorting. * @param options - Pagination, search, and sort options */ listCollections(options?: { page?: number; limit?: number; search?: string; sortBy?: "name" | "slug" | "createdAt" | "updatedAt"; sortOrder?: "asc" | "desc"; includeSchema?: boolean; }): Promise; /** * Get a single collection by name. * Enriches component fields with inline schemas for Admin UI rendering. * @param params - Parameters containing collection name */ getCollection(params: { collectionName: string; }): Promise; /** * Update a collection's metadata and/or schema. * @param params - Parameters containing collection name * @param body - Update data */ updateCollection(params: { collectionName: string; }, body: { label?: string; description?: string; icon?: string; group?: string; order?: number; sidebarGroup?: string; useAsTitle?: string; hidden?: boolean; /** Toggle cache revalidation. Honoured when defined; undefined leaves it unchanged. */ revalidate?: boolean; /** Toggle webhook recording. Honoured when defined; undefined leaves it unchanged. */ webhooks?: boolean; fields?: FieldDefinition[]; }): Promise; /** * Delete a collection. * @param params - Parameters containing collection name */ deleteCollection(params: { collectionName: string; }): Promise; /** * List entries in a collection with pagination. * @param params - Collection name, pagination options, and query filters */ listEntries(params: { collectionName: string; /** Page number (1-indexed, default: 1) */ page?: number; /** Number of documents per page (default: 10, max: 500) */ limit?: number; /** Search query to filter entries by searchable fields */ search?: string; /** Depth for relationship population */ depth?: number; /** Select specific fields to include */ select?: Record; /** Where clause for filtering */ where?: WhereFilter; /** * Output format for rich text fields. * - "json" (default): Return Lexical JSON structure only * - "html": Return HTML string only * - "both": Return object with both { json, html } properties */ richTextFormat?: RichTextOutputFormat; /** * Sort order for results. * Prefix with `-` for descending. * @example '-createdAt' for descending, 'title' for ascending */ sort?: string; /** User context for access control */ user?: UserContext; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * The route already ran the coarse RBAC gate, so skip only that redundant * re-check while the stored read rules (owner-only scoping, role-based, * custom) still run. The query service folds an owner-only rule into the SQL * predicate rather than filtering rows afterwards, so pagination and totals * stay correct. */ routeAuthorized?: boolean; /** * The caller's authenticated scope. A scoped API key is judged on its own * read grant rather than on the permissions of the user that owns it, so a * super-admin-owned key stays bound by a stored owner-only read rule. */ authenticatedScope?: AuthenticatedScope; /** * Draft/Published filter override (only effective when collection.status * === true). Public callers default to 'published'; trusted callers can * pass 'all' to see drafts too. Forwarded to query service as-is. */ status?: "published" | "draft" | "all"; /** Requested content locale (i18n M4) — forwarded to the query service. */ locale?: string; /** Fallback control (`false`/`"none"` disables fallback). */ fallbackLocale?: string | false; /** i18n M7: attach a per-locale `_translations` overview map to each row. */ translationStatus?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; }): Promise>>; /** * Create a new entry in a collection. * @param params - Collection name, optional user ID, and optional depth for relationship population * @param body - Entry data */ createEntry(params: { collectionName: string; userId?: string; userName?: string; userEmail?: string; /** Authenticated role set, forwarded to role-based access rules. */ userRoles?: string[]; /** Depth for relationship population in response (0-5) */ depth?: number; /** User context for access control */ user?: UserContext; /** Who performed the write, recorded on the outbox event. */ actor?: RequestActor; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** Write locale (i18n M5) — translatable values stored for this language. */ locale?: string; /** * Set by the REST dispatcher to attest the route middleware already ran * the RBAC/code-access gate, so the entry service skips only that * redundant re-check. Never inferred from a userId. */ routeAuthorized?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; /** * The caller's authenticated scope. For a scoped API-key REST create the * publish transition gate (create-as-published) judges the key's OWN grants. */ authenticatedScope?: AuthenticatedScope; /** Skip cache revalidation for this write (the outbox drain still runs). */ disableRevalidate?: boolean; }, body: Record): Promise>; /** * Get a single entry by ID. * @param params - Collection name, entry ID, and optional user ID */ getEntry(params: { collectionName: string; entryId: string; userId?: string; /** Depth for relationship population (0-5) */ depth?: number; /** Select specific fields to include */ select?: Record; /** * Output format for rich text fields. * - "json" (default): Return Lexical JSON structure only * - "html": Return HTML string only * - "both": Return object with both { json, html } properties */ richTextFormat?: RichTextOutputFormat; /** User context for access control */ user?: UserContext; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * Draft/Published filter override (only effective when collection.status * === true). Public callers default to 'published'; trusted callers can * pass 'all' to see drafts too. Forwarded to query service as-is. */ status?: "published" | "draft" | "all"; /** * Opt in to the working-draft overlay (draft/published split): a trusted * editor read returns the pending working draft in place of the live row. * Forwarded wholesale to the entry/query service, which gates it on an * update-capability probe, so a read-only caller passing it still sees the * live row. */ includeWorkingDraft?: boolean; /** Requested content locale (i18n M4) — forwarded to the query service. */ locale?: string; /** Fallback control (`false`/`"none"` disables fallback). */ fallbackLocale?: string | false; /** i18n M7: attach a per-locale `_translations` overview map to the entry. */ translationStatus?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; /** * Set by a route that already authenticated and authorized the caller. * Skips the redundant RBAC re-check (which resolves the caller's stored * roles and would reject a scoped API key) while leaving owner-only and * other document-level rules in force. */ routeAuthorized?: boolean; /** * The caller's authenticated scope. A scoped API key is judged on its OWN * read grant, so a super-admin-owned key does not skip the collection's * stored owner-only/custom read rule. */ authenticatedScope?: AuthenticatedScope; }): Promise>; /** * Remove a document's pending working-draft sidecar under the same parent-row * lock a draft save takes. Serializing the discard with concurrent draft saves * keeps it from deleting a draft another editor committed after this request's * authorization checks. The discard handler authorizes read and update first. */ discardWorkingDraft(params: { collectionName: string; entryId: string; }): Promise; /** * Count entries in a collection. * @param params - Collection name and optional filters */ countEntries(params: { collectionName: string; /** Search query to filter entries by searchable fields */ search?: string; /** Where clause for filtering */ where?: WhereFilter; /** User context for access control */ user?: UserContext; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * The route already ran the coarse RBAC gate, so skip only that redundant * re-check while the stored read rules (owner-only scoping, role-based, * custom) still run. Forwarded to the query service, which counts under the * same constraint listEntries filters by, so a total can never describe rows * the caller may not read. */ routeAuthorized?: boolean; /** * The caller's authenticated scope, mirroring listEntries so a scoped key's * count matches the rows it can list. */ authenticatedScope?: AuthenticatedScope; /** * Draft/Published filter override (only effective when collection.status * === true). Same semantics as listEntries. */ status?: "published" | "draft" | "all"; /** Requested content locale (i18n M4) — forwarded to the query service. */ locale?: string; /** Fallback control (`false`/`"none"` disables fallback). */ fallbackLocale?: string | false; /** Arbitrary data passed to hooks via context */ context?: Record; }): Promise>; /** * Update an existing entry. * @param params - Collection name, entry ID, optional user ID, and optional depth for relationship population * @param body - Update data */ updateEntry(params: { collectionName: string; entryId: string; userId?: string; userName?: string; userEmail?: string; /** Authenticated role set, forwarded to role-based access rules. */ userRoles?: string[]; /** Depth for relationship population in response (0-5) */ depth?: number; /** User context for access control */ user?: UserContext; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** Who performed the write, recorded on the outbox event. */ actor?: RequestActor; /** Write locale (i18n M5) — translatable values updated for this language. */ locale?: string; /** * Set by the REST dispatcher to attest the route middleware already ran * the RBAC/code-access gate, so the entry service skips only that * redundant re-check. Never inferred from a userId. */ routeAuthorized?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; /** * Set when this write restores an earlier version, recorded on the * version it captures. */ sourceVersionNo?: number; /** * The caller's authenticated scope. For a scoped API-key REST write, the * publish/unpublish transition gate judges the key's OWN grants. */ authenticatedScope?: AuthenticatedScope; /** Skip cache revalidation for this write (the outbox drain still runs). */ disableRevalidate?: boolean; }, body: Record): Promise>; /** * i18n M7: publish every language of an entry at once (spec §10). Sets the main status and, * for localized+draft collections, every companion `_status` to published, atomically. */ publishAllLocales(params: { collectionName: string; entryId: string; userId?: string; userName?: string; userEmail?: string; /** Authenticated role set, forwarded to role-based access rules. */ userRoles?: string[]; user?: UserContext; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * Set by the REST dispatcher to attest the route middleware already ran the * RBAC/code-access gate, so the entry service skips only that redundant * re-check. Never inferred from a userId. */ routeAuthorized?: boolean; /** API-key scope; gates the unconditional publish check. */ authenticatedScope?: AuthenticatedScope; /** Acting identity from the transport, forwarded to the recorded event. */ actor?: RequestActor; }): Promise>; /** * Delete an entry. * @param params - Collection name, entry ID, and optional user ID */ deleteEntry(params: { collectionName: string; entryId: string; userId?: string; userName?: string; userEmail?: string; /** Authenticated role set, forwarded to role-based access rules. */ userRoles?: string[]; /** User context for access control */ user?: UserContext; /** Who performed the delete, recorded on the outbox event. */ actor?: RequestActor; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * Set by the REST dispatcher to attest the route middleware already ran * the RBAC/code-access gate, so the entry service skips only that redundant * re-check. Never inferred from a userId — a caller attributing a user for * hooks/audit must still pass the permission gate. */ routeAuthorized?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; /** * The caller's authenticated scope. A scoped API key is judged on its OWN * delete grant, so the session super-admin bypass does not apply to it. */ authenticatedScope?: AuthenticatedScope; /** Skip cache revalidation for this delete (the outbox drain still runs). */ disableRevalidate?: boolean; }): Promise>; /** * Bulk delete multiple entries by IDs. * Uses partial success pattern - some entries may fail while others succeed. * @param params - Collection name and array of entry IDs to delete * @returns Bulk operation result with success/failed arrays and counts */ bulkDeleteEntries(params: { collectionName: string; ids: string[]; userId?: string; userName?: string; userEmail?: string; /** Authenticated role set, forwarded to role-based access rules. */ userRoles?: string[]; /** User context for access control */ user?: UserContext; /** Who performed the delete, recorded on each entry's outbox event. */ actor?: RequestActor; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * Set by the REST dispatcher to attest the route middleware already ran * the RBAC/code-access gate, so the entry service skips only that redundant * re-check. Never inferred from a userId — a caller attributing a user for * hooks/audit must still pass the permission gate. */ routeAuthorized?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; /** * The caller's authenticated scope. Each per-id delete is judged on a scoped * API key's OWN delete grant, not the key owner's. */ authenticatedScope?: AuthenticatedScope; /** Skip cache revalidation for this bulk delete (the outbox drain still runs). */ disableRevalidate?: boolean; }): Promise>; /** * Bulk update multiple entries with the same data. * Uses partial success pattern - some entries may fail while others succeed. * @param params - Collection name, array of entry IDs, and update data * @returns Bulk operation result with success/failed arrays and counts */ bulkUpdateEntries(params: { collectionName: string; ids: string[]; data: Record; userId?: string; userName?: string; userEmail?: string; /** Authenticated role set, forwarded to role-based access rules. */ userRoles?: string[]; /** User context for access control */ user?: UserContext; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * Set by the REST dispatcher to attest the route middleware already ran * the RBAC/code-access gate, so the entry service skips only that redundant * re-check. Never inferred from a userId — a caller attributing a user for * hooks/audit must still pass the permission gate. */ routeAuthorized?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; /** Acting identity from the transport, forwarded to the recorded event. */ actor?: RequestActor; /** * The caller's authenticated scope. For a scoped API-key bulk update each * per-id publish/unpublish transition is judged on the key's OWN grants. */ authenticatedScope?: AuthenticatedScope; /** Skip cache revalidation for this bulk update (the outbox drain still runs). */ disableRevalidate?: boolean; }): Promise>>; /** * Bulk update entries matching a where clause. * Uses partial success pattern - some entries may fail while others succeed. * @param params - Collection name, where clause, and update data * @param options - Optional limit for safety (default: 1000) * @returns Bulk operation result with success/failed arrays and counts */ bulkUpdateByQuery(params: { collectionName: string; where: WhereFilter; data: Record; userId?: string; userName?: string; userEmail?: string; /** Authenticated role set, forwarded to role-based access rules. */ userRoles?: string[]; /** User context for access control */ user?: UserContext; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** Route auth already ran; response is still redacted for this user */ routeAuthorized?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; /** Acting identity from the transport, forwarded to the recorded event. */ actor?: RequestActor; /** * The caller's authenticated scope. Judges the collection-level gate and * each per-row transition on a scoped API key's OWN grants. */ authenticatedScope?: AuthenticatedScope; /** Skip cache revalidation for this bulk update (the outbox drain still runs). */ disableRevalidate?: boolean; }, options?: { limit?: number; }): Promise>>; /** * Bulk delete entries matching a where clause. * Uses partial success pattern - some entries may fail while others succeed. * @param params - Collection name, where clause, and optional access control options * @param options - Optional limit for safety (default: 1000) * @returns Bulk operation result with success/failed arrays and counts */ bulkDeleteByQuery(params: { collectionName: string; where: WhereFilter; /** User context for access control */ user?: UserContext; /** Who performed the delete, recorded on each entry's outbox event. */ actor?: RequestActor; /** * The caller's authenticated scope. A scoped API key is judged on its own * delete grant for the owner-predicate enumeration and each per-row delete. */ authenticatedScope?: AuthenticatedScope; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * Set by the REST dispatcher to attest the route middleware already ran * the RBAC/code-access gate, so the entry service skips only that * redundant re-check. Never inferred from a userId. */ routeAuthorized?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; /** Skip cache revalidation for this bulk delete (the outbox drain still runs). */ disableRevalidate?: boolean; }, options?: { limit?: number; }): Promise>; /** * Duplicate an existing entry. * Creates a new entry with the same field values as the source entry. * System fields (id, createdAt, updatedAt) are regenerated. * Title/name fields get " (Copy)" appended. * @param params - Collection name, entry ID to duplicate, and optional overrides * @returns The newly created duplicate entry */ duplicateEntry(params: { collectionName: string; entryId: string; userId?: string; userName?: string; userEmail?: string; /** Authenticated role set, forwarded to role-based access rules. */ userRoles?: string[]; /** Optional field overrides to apply to the duplicated entry */ overrides?: Record; /** User context for access control */ user?: UserContext; /** When true, bypass all access control checks */ overrideAccess?: boolean; /** * Which collections a trusted read may reach as relationships are expanded. * Absent means every populated target inherits the caller's trust. Only ever * narrows, and never admits a target's drafts. */ trusted?: (collection: string) => boolean; /** * Set by the REST dispatcher to attest the route middleware already ran * the RBAC/code-access gate, so the entry service skips only that redundant * re-check. Never inferred from a userId — a caller attributing a user for * hooks/audit must still pass the permission gate. */ routeAuthorized?: boolean; /** Arbitrary data passed to hooks via context */ context?: Record; /** Acting identity from the transport, forwarded to the recorded event. */ actor?: RequestActor; /** * The caller's authenticated scope. A duplicate is a create, so a scoped * API key copying a published source is judged on the key's OWN grant. */ authenticatedScope?: AuthenticatedScope; /** Skip cache revalidation for this duplicate (the outbox drain still runs). */ disableRevalidate?: boolean; }): Promise>; /** * Get the underlying CollectionMetadataService for direct access. * Useful for advanced use cases requiring fine-grained control. */ getMetadataService(): CollectionMetadataService; /** * Get the underlying CollectionEntryService for direct access. * Useful for advanced use cases requiring fine-grained control. */ getEntryService(): CollectionEntryService; /** * Get the underlying CollectionRelationshipService for direct access. * Useful for advanced use cases requiring fine-grained control. */ getRelationshipService(): CollectionRelationshipService; } /** * Activity Log Service * * Records and queries user activity (create/update/delete) across all * collections. Designed for the dashboard activity feed — not a full * audit log. Writes are fire-and-forget; failures never propagate to * the caller. * * @module services/dashboard/activity-log-service * @since 1.0.0 */ /** The three mutation actions tracked in the activity log. */ type ActivityLogAction = "create" | "update" | "delete"; /** A single activity log record as returned by queries. */ interface ActivityLogEntry { id: string; /** * The actor, as an opaque reference that outlives their account. * * Still set after the account is deleted — that is what keeps one deleted * actor's entries distinguishable from another's. */ userId: string; /** NULL once the actor's account was deleted and their identity erased. */ userName: string | null; /** NULL once the actor's account was deleted and their identity erased. */ userEmail: string | null; action: ActivityLogAction; collection: string; entryId: string | null; entryTitle: string | null; metadata: Record | null; createdAt: string; /** * When THIS ROW's identity was erased. NULL while the actor still exists. * * The row's own erasure, deliberately, not the account's deletion. For an * entry erased by a deletion the two coincide, because the erasure runs * inside that transaction. For one written after the account was already * gone they do not: nothing retains when that deletion happened, and * claiming otherwise would put a number in an audit field that no record * supports. Separate from a NULL name because "erased" and "never carried a * name" are different facts, and only this one answers when. */ identityErasedAt: string | null; } /** Input for recording a new activity. */ interface LogActivityInput { userId: string; /** * Display name to denormalize onto the row. Omit to take it from the account * itself, which is what a caller that holds only an actor id does — the write * already reads that row to decide whether the account still exists, so the * name comes from the same look, under the same lock, as that decision. */ userName?: string; /** Email to denormalize onto the row; omit to take it from the account. */ userEmail?: string; action: ActivityLogAction; collection: string; entryId?: string; entryTitle?: string; metadata?: Record; } /** Paginated activity log response. */ interface ActivityLogResult { activities: ActivityLogEntry[]; total: number; hasMore: boolean; } /** Options for querying the activity log. */ interface ActivityLogQueryOptions { limit?: number; offset?: number; collection?: string; userId?: string; } /** * The Drizzle surface an activity write needs. * * Structural rather than the concrete types because the real ones are * dialect-specific (NodePgDatabase / MySql2Database / BetterSQLite3Database), * while the fluent API is identical. */ interface ActivityWriteDb { insert(table: unknown): { values(data: unknown): Promise; }; select(fields: unknown): { from(table: unknown): { where(condition: unknown): { limit(count: number): Promise[]> & { for(strength: "share"): Promise[]>; }; }; }; }; } declare class ActivityLogService extends BaseService { constructor(adapter: DrizzleAdapter, logger: Logger); /** * The columns of one entry that erasure never touches. * * The identity columns are decided by the write itself, against an account * that may be being deleted at that moment, so they are supplied separately. */ private entryValues; /** * Record an activity log entry as a self-contained write. * * For callers that own no transaction of their own — the auth and account * seams. Supplies the transaction the identity decision needs (see * {@link logActivityInTx}) and swallows failures: these callers have already * committed by the time they log, so a throw here could only turn a recorded * action into a failed one. A mutation recording inside its own write * transaction wants the opposite and calls {@link logActivityInTx} directly. */ logActivity(input: LogActivityInput): Promise; /** * Write one activity entry through an executor the CALLER owns. * * Holds the whole erasure-aware identity decision, so the two writers — the * standalone {@link logActivity} above and the mutation seam that records * inside a content transaction — cannot come to disagree about what a row may * carry. Both dialect mechanisms live here; only the transaction the * statements run in differs between callers. * * Failures PROPAGATE, deliberately. A caller recording inside a content * transaction needs the write to fail with it — an entry that cannot be * written must take the change it describes down with it, rather than leaving * a committed mutation nothing recorded. Swallowing is the standalone * caller's decision to make, and it makes it above. * * The identity a row may carry has to be decided against an account that may * be deleted at this very moment, and the two dialect families need * different mechanisms for it. * * **Postgres and MySQL** first take a SHARED lock on the account row. * `deleteUser` takes an EXCLUSIVE lock on that row before it erases anything, * so the two cannot be in flight at once: either this lock is taken first and * the deletion waits, so its erasure covers a row that already exists, or the * deletion holds the row and this waits for its commit and then correctly * finds the account gone. The lock is what closes the gap a single statement * cannot — its subquery is answered when it STARTS while its row becomes * visible when it COMMITS, and an insert spanning the deletion's commit * satisfies neither the deletion's own erasure nor its post-commit sweep. * Shared rather than exclusive so concurrent writes by the same author do not * serialise against each other; only the deletion has to exclude them, for * the length of one insert. * * **SQLite** has one writer, so its insert cannot interleave with the * deletion's transaction at all and needs no lock. It decides the identity * inside the statement instead, because a check followed by a separate * insert would leave a durable row that a second statement was still going * to correct. */ logActivityInTx(db: ActivityWriteDb, input: LogActivityInput): Promise; /** * Query recent activity log entries with optional filters. * * Uses the `limit + 1` pattern to determine `hasMore` without a * separate COUNT query. The `total` field uses a separate count query * only when needed. */ getRecentActivity(options?: ActivityLogQueryOptions): Promise; private countActivities; private mapRow; } /** * Dashboard Service * * Aggregates content-centric statistics, recent entries across collections, * and project-wide metrics for the admin dashboard. Uses the database adapter * directly for simple read-only aggregate queries — no hooks, access control, * or relationship expansion needed for dashboard stats. * * @module services/dashboard/dashboard-service * @since 1.0.0 */ /** Content statistics for the hero stats row. */ interface ContentStats { totalEntries: number; totalMedia: number; contentTypes: number; recentChanges24h: number; } /** Draft vs Published breakdown. */ interface ContentStatus { published: number; draft: number; } /** Per-collection entry count for collection quick-links. */ interface CollectionCount { slug: string; label: string; group: string | null; count: number; } /** Full dashboard stats response. */ interface DashboardStatsResponse { content: ContentStats; status: ContentStatus; collectionCounts: CollectionCount[]; users: number; roles: number; permissions: number; fieldGroups: number; singles: number; apiKeys: number; } /** A recently edited entry across any collection. */ interface RecentEntry { id: string; title: string; collectionSlug: string; collectionLabel: string; status: "published" | "draft" | "none"; updatedAt: string; } /** Response for the recent entries endpoint. */ interface RecentEntriesResponse { entries: RecentEntry[]; } /** Single stat item for the project statistics grid. */ interface ProjectStat { key: string; label: string; value: number; } /** Response for the project stats endpoint. */ interface ProjectStatsResponse { stats: ProjectStat[]; } declare class DashboardService extends BaseService { constructor(adapter: DrizzleAdapter, logger: Logger); /** * Get aggregated dashboard statistics. * * Runs all count queries in parallel for fast response. Uses the database * adapter directly for simple COUNT(*) queries. */ getStats(options?: { readableResources?: Set; }): Promise; /** * Get recently modified entries across all collections. * * Queries each registered collection for entries sorted by `updated_at DESC`, * merges results, and returns the top N entries. Capped at 20 collections * to prevent excessive DB queries on large installations. * * @param limit - Maximum number of entries to return (default: 5, max: 20) */ getRecentEntries(limit?: number, readableResources?: Set): Promise; /** * Get project-wide statistics for the stats grid. * * Returns an array of stat items for display in the 2×4 grid widget. * Reuses the same data sources as `getStats()`. */ getProjectStats(options?: { readableResources?: Set; }): Promise; private getRegisteredCollections; private getRegisteredSingles; /** * Format a Date for raw-SQL bind parameters per dialect. * * Phase A follow-up (2026-05-01) — `BaseService.formatDateForDb()` * returns the Date unchanged; that works for Drizzle's typed query * builder (which converts based on column mode) but breaks raw * `adapter.executeQuery(sql, [date])` paths on SQLite, where * better-sqlite3 throws "can only bind numbers, strings, bigints, * buffers, and null" on Date objects. * * Per-dialect format: * - SQLite: epoch SECONDS (matches Drizzle's `integer mode:"timestamp"` * storage, which is what every timestamp column in the schema uses). * - MySQL: 'YYYY-MM-DD HH:MM:SS' (DATETIME/TIMESTAMP format). * - PostgreSQL: ISO 8601 string (driver converts to timestamp natively). * * Helper kept local to this service since it's the only raw-query * consumer; promote to BaseService if more services need it. */ private dateForRawBind; private countTable; private countActiveApiKeys; private countRecentChanges24h; private countRegistryItems; private getCollectionCounts; /** * Get draft vs published content breakdown across all collections. * * Collections without a `_status` or `status` field count all entries * as published. */ private getContentStatusBreakdown; private countByStatus; private getRecentFromCollection; } /** * FieldGroupSchemaService generates database schemas for component data tables (`comp_{slug}`). * Supports PostgreSQL, MySQL, and SQLite dialects. */ type SupportedDialect$1 = "postgresql" | "mysql" | "sqlite"; /** * What `generateRuntimeSchema` needs beyond the fields. * * `typeColumn` is required rather than defaulted. The storage migration renames * the discriminator, so the right physical name is a property of the database in * front of us, and a default would let a call site that never learned to resolve * it compile and then silently project a column that is not there. Required * makes the type checker the completeness proof. Callers supply either the * resolved name from the catalog or, for a table this process just created, * `STORAGE_FORMAT.columns.type` — the same constant the DDL just wrote. */ interface RuntimeSchemaOptions { localized?: boolean; typeColumn: string; } declare class FieldGroupSchemaService { private readonly dialect; private readonly q; constructor(dialect?: SupportedDialect$1); /** * Generate SQL migration for creating a new component data table. */ generateMigrationSQL(tableName: string, fields: FieldConfig[], options?: { localized?: boolean; }): string; /** * Generate ALTER TABLE migration for updating a component data table. */ generateAlterTableMigration(tableName: string, oldFields: FieldConfig[], newFields: FieldConfig[]): string; /** * Generate DROP TABLE migration for a component data table. */ generateDropTableMigration(tableName: string): { migrationSQL: string; migrationFileName: string; }; /** * Generate a Drizzle table object at runtime for querying component data. */ generateRuntimeSchema(tableName: string, fields: FieldConfig[], options: RuntimeSchemaOptions): unknown; private generatePostgresSchema; private generateMySQLSchema; private generateSQLiteSchema; /** * A field as the column mappers below can read it. * * They switch on `field.type`, which for a plugin-contributed type matches no * case. Substituting the storage primitive's built-in type makes them emit * the column that type persists as — the same substitution * `getColumnDescriptor` makes for collections and singles, so a component * column matches what the schema pipeline creates for it. */ /** * Refuse a `unique` field the dialect cannot enforce, before any DDL is generated for it. * * Asks the shared rule rather than restating it, so a component, a collection and the desired * schema cannot hold different opinions about the same column. MySQL refuses to key an * unbounded TEXT/BLOB in either spelling and cannot index JSON at all, and because it commits * each DDL statement separately there is no way to attempt the constraint without risking a * table that exists WITHOUT it. A bare column is also exactly what the desired schema declares * for an unkeyable type, so a half-applied add would read as converged and the guarantee would * disappear silently. */ private assertUniquenessEnforceable; private asMappableField; /** * The column type this dialect gives `field`, spelled exactly as the CREATE TABLE spells it. * * The same value `generateColumnSQL` puts into its column definition, exposed so a caller that * needs only the type can ask for it instead of recovering it from rendered SQL. Recovering it * that way loses information the moment a type is more than one word — `DOUBLE PRECISION` reads * back as `DOUBLE` — and no amount of parsing makes a printed statement a reliable channel for a * value the function already returns. * * `null` where this service would emit no column at all, which is the same condition that makes * `generateColumnSQL` skip a field. */ columnTypeFor(field: DataFieldConfig): string | null; /** * The DEFAULT expression each SYSTEM column is created with, by column name. * * These belong to no field, so nothing derived from the field list can describe them — and they * are not decoration: the generated runtime schema declares `_order` and the timestamps as * DATABASE defaults, so Drizzle OMITS those columns from an INSERT and the database supplies the * value. A dropped default therefore fails a NOT NULL insert outright, or silently stores NULL * where a zero was intended. * * Rendered into the CREATE TABLE below rather than restated there, so the defaults a caller can * ask about and the ones the table actually gets are one expression. */ structuralColumnDefaults(): ReadonlyMap; /** * The DEFAULT expression this dialect gives `field`, or `null` where it writes none. * * The companion of {@link columnTypeFor}, and exposed for the same reason: a caller comparing a * live column against what this service would have created needs the value, and the only other * way to obtain it is to render a statement and read it back out. `null` is a real answer here — * most field types carry no default — which is why it is distinct from the "could not decide" * a caller may need to represent separately. */ columnDefaultFor(field: DataFieldConfig): string | null; private generateColumnSQL; private numberColumnKind; private decimalDimensions; private getColumnType; private mapFieldToPostgresColumn; private mapFieldToMySQLColumn; private mapFieldToSQLiteColumn; /** * The identifiers a rendered migration would put in front of the database, with their lengths. * * 🔴 Read out of the SQL rather than re-derived from the field list, and the difference is not * stylistic. An enumeration that walks the fields again is a SECOND transcription of the rules * this renderer applies — which index names it emits, which fields it skips because they are * localized and live in the companion, which get a `uq_` rather than an `idx_`, and that a column * name is itself an identifier. A first attempt at that missed unique indexes and plain column * names, and wrongly rejected localized fields for an index the renderer never emits. Three ways * to be wrong, in rules that had already been written down once. * * Scanning what was actually rendered cannot drift, because it IS the output. Every identifier * this service emits is quoted with `this.q`, and no dialect uses that character for string * literals — PostgreSQL and SQLite quote strings with `'`, MySQL identifiers are backticked — so * the quoted tokens are identifiers and nothing else. */ identifiersIn(sql: string): string[]; private fieldHasForeignKey; private isFieldModified; private buildFieldMap; private getDefaultValueForType; private formatDefaultValue; private toSnakeCase; private toPascalCase; } /** * Shared type definitions for the General Settings schema. * * @module schemas/site-settings/types * @since 1.0.0 */ /** * Full record type for the `site_settings` singleton row. * The `id` is always `'default'`. */ interface GeneralSettingsRecord { /** Always 'default' — enforces singleton pattern. */ id: string; /** Display name for the application (used in admin UI title, email templates). */ applicationName: string | null; /** Primary URL where the site is hosted (used for email links). */ siteUrl: string | null; /** Primary email address for administrative notifications / default sender. */ adminEmail: string | null; /** IANA timezone identifier, e.g. 'America/New_York'. */ timezone: string | null; /** Date display format string, e.g. 'MM/DD/YYYY'. */ dateFormat: string | null; /** Time display format: '12h' or '24h'. */ timeFormat: string | null; /** URL of the logo image shown in the admin sidebar and auth pages. */ logoUrl: string | null; /** JSON array of custom sidebar groups for admin navigation. */ customSidebarGroups: string | null; /** JSON object mapping plugin slugs to their sidebar placement group overrides. */ pluginPlacements: string | null; /** * Revocation generation for preview links — the generation every preview * token records and is verified against. Incrementing it invalidates every * link ever issued. Monotonic: it only ever moves forward. */ previewTokenGeneration: number; /** When the settings were last updated. */ updatedAt: Date; } /** * Fields that can be updated via the settings form. * * Excludes immutable `id` and auto-managed `updatedAt`, and * `previewTokenGeneration`, which is a revocation counter rather than a * setting: it must only ever be incremented by an explicit revoke, because * writing a lower value would re-validate every preview link that revoke had * already invalidated. */ type GeneralSettingsUpdate = Omit; /** * General Settings Service * * Manages the `site_settings` singleton row — a single record * (id = 'default') that stores application-level configuration: * application name, site URL, admin email, timezone, and display formats. * * @module services/general-settings/general-settings-service * @since 1.0.0 */ interface CustomSidebarGroup { slug: string; name: string; icon?: string; } declare class GeneralSettingsService extends BaseService { private siteSettings; constructor(adapter: DrizzleAdapter, logger: Logger); private toRecord; /** * Retrieve the current general settings. * Returns an all-null record if the singleton row has not been saved yet. */ getSettings(): Promise; /** * Get the configured IANA timezone identifier. * Reads from the singleton row each call so updates are reflected * consistently across long-lived runtime instances. */ getTimezone(): Promise; /** * The current preview-link revocation generation. * * Read on every mint and every verification, so a revoke reaches sessions * already in flight rather than only new links. */ getPreviewTokenGeneration(): Promise; /** * Invalidate every preview link ever issued, and return the new generation. * * The increment is computed by the DATABASE rather than read-then-written, * so two administrators revoking at once cannot both write the same value * and leave one of the two revocations undone. * * Creates the singleton row when it does not exist yet: an installation that * has never opened the settings form still has to be able to revoke, and * generation 1 correctly refuses tokens minted at the implicit 0. */ revokeAllPreviewTokens(): Promise; /** * Upsert the general settings singleton row. * Only the provided fields are updated; omitted fields are left unchanged. * If the row doesn't exist yet, it is created with the provided values. */ updateSettings(data: Partial): Promise; /** * Parse the stored JSON string into an array of custom sidebar groups. * Returns an empty array if no groups are stored or JSON is invalid. */ getCustomSidebarGroups(settings: GeneralSettingsRecord): CustomSidebarGroup[]; /** * Replace all custom sidebar groups with the provided array. * Persists as a JSON string in the `custom_sidebar_groups` column. */ updateCustomSidebarGroups(groups: CustomSidebarGroup[]): Promise; } /** * Media Service * * Handles CRUD operations for media files with integrated storage and image processing * * Features: * - Auto-detects storage (Vercel Blob, S3, R2, or local filesystem) * - Automatic thumbnail generation for images * - Image optimization (compression, WebP conversion) * - Pagination, search, filtering, sorting * - File validation and error handling * - Retry logic with exponential backoff for transient storage failures * * @example * ```typescript * const mediaService = new MediaService(adapter, logger); * * // Upload image * const result = await mediaService.uploadMedia({ * file: buffer, * filename: 'photo.jpg', * mimeType: 'image/jpeg', * size: 1024000, * uploadedBy: userId, * }); * ``` */ declare class MediaService$1 extends BaseService { /** * Shared post-response drain fast path. This service records media outbox * events directly, so callers that reach it WITHOUT the unified media * service (the exported server actions via `ServiceContainer.media`) still * get the immediate drain. Left undefined when the unified service wraps * this one (that wrapper offers the drain itself), so the drain is offered * exactly once per write. */ private readonly fastDrainScheduler?; /** * Prunes after a write, paired with the drain fast path. The shared runner * carries both passes — the webhook outbox and the audit trails — each on * its own window and gate, and is absent only when neither has anything to * prune. */ private readonly retentionRunner?; private get storage(); private get imageProcessor(); private static readonly WRITE_PATH_PRUNE_BATCHES; constructor(adapter: DrizzleAdapter, logger: Logger, /** * Shared post-response drain fast path. This service records media outbox * events directly, so callers that reach it WITHOUT the unified media * service (the exported server actions via `ServiceContainer.media`) still * get the immediate drain. Left undefined when the unified service wraps * this one (that wrapper offers the drain itself), so the drain is offered * exactly once per write. */ fastDrainScheduler?: WebhookFastDrainScheduler | undefined, /** * Prunes after a write, paired with the drain fast path. The shared runner * carries both passes — the webhook outbox and the audit trails — each on * its own window and gate, and is absent only when neither has anything to * prune. */ retentionRunner?: RetentionRunner | undefined); /** * Post-write webhook maintenance for callers that use this service directly: * offer the fast drain, then a short retention pass. No-op when no scheduler * was injected (the unified media service handles the drain in that path). * Both absorb their own failures, so this never turns a committed write into * an error. */ private afterWrite; /** * List media with pagination, search, filtering, and sorting */ listMedia(params?: MediaParams): Promise; /** * Get media by ID */ getMediaById(mediaId: string): Promise; /** * Upload media file with automatic processing and storage */ uploadMedia(input: UploadMediaInput$1, actor?: RequestActor): Promise; /** * Insert a random token before a filename's extension so any destination * derived from it is unique: `photo.jpg` -> `photo-.jpg` (a name with * no extension gets the token appended). Used to guarantee regenerated * focal-crop variants never reuse the item's existing storage keys, even on a * storage adapter that maps a filename to a fixed path. */ private uniqueVariantBaseName; /** * Regenerate a media item's image size variants for a changed crop point, * writing `sizes`/`thumbnailUrl` onto `updateData` and returning the newly * generated sizes (or null when nothing was regenerated). No-op unless the * crop actually changed and the item is an image on a readable storage * adapter. * * The new variants are written to fresh storage keys — a random token is * injected into the destination filename here so they can NEVER land on the * item's existing variant keys, even on a storage adapter that derives a * deterministic path from the filename rather than prefixing its own random * id. This never overwrites the item's existing variant bytes and never * deletes them: content-addressing plus a delete-after-commit in `updateMedia` * is what makes this safe. Deleting the old paths here — before the row commits * — was the previous bug: a rollback or a concurrent edit left the committed * row pointing at bytes that were already gone. The old paths are now cleaned * up by the caller only after the row durably points at the new ones. Kept * OUTSIDE the write transaction on purpose: it must not hold the row lock (or a * single-connection pool) across slow storage I/O. * * Best-effort: a regeneration failure is swallowed (the crop point is still * saved against the existing variants) rather than failing the whole update. */ private regenerateFocalPointSizes; /** * Best-effort deletion of a set of variant storage paths. Used to clean up * either the superseded old variants (after a rotation commits) or the * freshly-written new variants (when the commit did not happen), so neither a * successful regeneration nor a failed one leaks storage. Never throws: a * failed delete is logged, because the durable DB state is already correct and * an orphaned file is not worth failing or reversing the write over. */ private deleteVariantPaths; /** * Extract the storage paths from a `sizes` value (an object keyed by size name, * each holding `{ path, ... }`), tolerating the DB's JSON-string form. Used to * decide which variant files to delete after a rotation without touching any * that the new set reuses. */ /** * Delete the variant paths in `candidateSizes` that are NOT among the paths * `keepSizes` references. Used for every variant cleanup so a deterministic-key * adapter (where a regenerated variant can reuse an existing path) never * deletes a file the surviving row still points at: after commit `keepSizes` * is the new sizes and `candidateSizes` the superseded old ones; on a failed or * void write it is the reverse — the old sizes survive on the un-updated row, * so only the genuinely-orphaned new uploads are removed. */ private deleteSupersededVariants; private collectVariantPaths; /** * Update media metadata (altText, caption, tags) */ updateMedia(mediaId: string, changes: UpdateMediaInput$1, actor?: RequestActor): Promise; /** * Delete media file (removes from storage and database) */ deleteMedia(mediaId: string, actor?: RequestActor): Promise; /** * Upload multiple media files in parallel (with concurrency limit) * * Uploads files in batches of 5 concurrent uploads to avoid overwhelming * the server while still providing good performance. * * @param inputs - Array of files to upload * @returns Object with success status and individual results for each file * * @example * ```typescript * const result = await mediaService.uploadMediaBulk([ * { file: buffer1, filename: 'photo1.jpg', mimeType: 'image/jpeg', size: 1024, uploadedBy: userId }, * { file: buffer2, filename: 'photo2.jpg', mimeType: 'image/jpeg', size: 2048, uploadedBy: userId }, * ]); * * console.log(`Uploaded ${result.results.filter(r => r.success).length} of ${result.results.length} files`); * result.results.forEach(r => { * if (r.success) { * console.log(`✓ ${r.filename}`); * } else { * console.log(`✗ ${r.filename}: ${r.error}`); * } * }); * ``` */ uploadMediaBulk(inputs: UploadMediaInput$1[]): Promise<{ success: boolean; totalFiles: number; successCount: number; failureCount: number; results: Array<{ filename: string; success: boolean; data?: Media; error?: string; statusCode?: number; }>; }>; /** * Delete multiple media files in parallel * * @param mediaIds - Array of media IDs to delete * @returns Object with success status and individual results */ deleteMediaBulk(mediaIds: string[]): Promise<{ success: boolean; totalFiles: number; successCount: number; failureCount: number; results: Array<{ mediaId: string; success: boolean; error?: string; }>; }>; /** * Get storage type being used */ getStorageType(): string; } /** * Media Folder Service * * Handles CRUD operations for media folder organization with nested hierarchy support. * * Features: * - Create/read/update/delete folders * - Nested folder hierarchy (subfolders) * - Move media files between folders * - List folder contents (subfolders + media files) * - Breadcrumb navigation support * * @example * ```typescript * const folderService = new MediaFolderService(adapter, logger); * * // Create a folder * const result = await folderService.createFolder({ * name: 'Product Images', * description: 'All product photos', * createdBy: userId, * }); * * // Create a subfolder * await folderService.createFolder({ * name: 'Electronics', * parentId: productImagesId, * createdBy: userId, * }); * * // Move media to folder * await folderService.moveMediaToFolder(mediaId, folderId); * ``` */ interface MediaFolder$1 { id: string; name: string; description: string | null; parentId: string | null; createdBy: string; createdAt: Date; updatedAt: Date; } interface CreateFolderInput$1 { name: string; description?: string; parentId?: string; createdBy: string; } interface UpdateFolderInput$1 { name?: string; description?: string; parentId?: string; } interface FolderContents$1 { folder: MediaFolder$1; subfolders: MediaFolder$1[]; mediaFiles: Record[]; breadcrumbs: Array<{ id: string; name: string; }>; } interface FolderResponse { success: boolean; statusCode: number; code?: ServiceErrorCode; message: string; data?: MediaFolder$1 | null; } interface FolderListResponse { code?: ServiceErrorCode; success: boolean; statusCode: number; message: string; data?: MediaFolder$1[]; } interface FolderContentsResponse { code?: ServiceErrorCode; success: boolean; statusCode: number; message: string; data?: FolderContents$1; } declare class MediaFolderService extends BaseService { constructor(adapter: DrizzleAdapter, logger: Logger); private hasFolderSchema; /** * Create a new folder */ createFolder(input: CreateFolderInput$1): Promise; /** * Get folder by ID */ getFolderById(folderId: string): Promise; /** * List root folders (no parent) */ listRootFolders(createdBy?: string): Promise; /** * List subfolders of a folder */ listSubfolders(parentId: string): Promise; /** * Get folder contents (subfolders + media files) */ getFolderContents(folderId: string | null): Promise; private getBreadcrumbs; /** * Update folder */ updateFolder(folderId: string, updates: UpdateFolderInput$1): Promise; private isSubfolder; private collectAllSubfolderIds; /** * Delete folder (and optionally its contents) */ deleteFolder(folderId: string, deleteContents?: boolean, storage?: { bulkDelete(filePaths: string[], collection?: string): Promise<{ successful: string[]; failed: Array<{ filePath: string; error: string; }>; }>; }): Promise<{ success: boolean; statusCode: number; code?: ServiceErrorCode; message: string; deletedMedia?: number; deletedFolders?: number; }>; /** * Move media file to folder */ moveMediaToFolder(mediaId: string, folderId: string | null): Promise<{ success: boolean; statusCode: number; code?: ServiceErrorCode; message: string; }>; } /** * Upload Validation — Shared Types * * @module services/upload-validation/types */ /** * Single validation error entry. Identical to one element of * `ValidationPublicData.errors`, so the array can be passed straight to * `NextlyError.validation({ errors })` without remapping. */ type UploadValidationError = ValidationPublicData["errors"][number]; interface ValidationSuccess { ok: true; value: ValidatedFile; } interface ValidationFailure { ok: false; errors: UploadValidationError[]; /** Operator-only detail (sniffed type, sizes, reasons); never surfaced to clients per the §13.8 rubric. */ logContext: Record; } type ValidationResult = ValidationSuccess | ValidationFailure; interface ValidatedFile { /** Bytes to persist. For SVGs, this is the sanitized output, never the input. */ buffer: Buffer; filename: string; mimeType: string; /** Drives `Content-Disposition: attachment` on storage upload when `svgCsp` is enabled. */ isSvg: boolean; } interface ValidationConfig { /** Full override of the allowlist; when set, `additionalMimeTypes` is ignored. */ allowedMimeTypes?: string[]; /** Merged with `DEFAULT_ALLOWED_MIME_TYPES` when `allowedMimeTypes` is not provided. */ additionalMimeTypes?: string[]; /** Max overall file size in bytes (sourced from `security.limits.fileSize`). */ maxSize: number; /** Stricter SVG cap; bounds XML-parser work to defang entity-expansion DoS. */ maxSvgSize: number; } /** * Narrow shape of the `security` block the validator reads from. Decoupled * from the full `SanitizedNextlyConfig` so the validator stays insensitive * to unrelated config-schema changes. The validator itself only uses * `uploads.allowedMimeTypes`, `uploads.additionalMimeTypes`, and * `limits.fileSize`; `uploads.svgCsp` is included so callers that pass * the same block to services like `MediaService` don't need a second * type. */ interface SecurityBlockLike { uploads?: { allowedMimeTypes?: string[]; additionalMimeTypes?: string[]; svgCsp?: boolean; }; limits?: { fileSize?: string | number; }; } /** * Upload Validation — DI Wrapper * * @module services/upload-validation/upload-validator */ /** * Holds a resolved `ValidationConfig` and exposes `validate()` for upload * pipelines. Stateless aside from the config snapshot; safe to register * as a singleton. * * @example * const validator = new UploadValidator(config.security); * const result = await validator.validate({ buffer, filename, mimeType }); */ declare class UploadValidator { private readonly _config; constructor(security: SecurityBlockLike | undefined); validate(input: { buffer: Buffer; filename: string; mimeType: string; }): Promise; config(): Readonly; } /** * Media Domain Types * * Type definitions for the unified media service layer. * These types represent the public API surface for media operations. */ /** * Media file returned from operations */ interface MediaFile { id: string; filename: string; originalFilename: string; mimeType: string; size: number; width?: number | null; height?: number | null; duration?: number | null; url: string; thumbnailUrl?: string | null; altText?: string | null; caption?: string | null; tags?: string[] | null; folderId?: string | null; uploadedBy?: string | null; uploadedAt: Date; updatedAt: Date; } /** * Input for uploading a media file */ interface UploadMediaInput { /** File content as Buffer */ buffer: Buffer; /** Original filename */ filename: string; /** MIME type (e.g., 'image/jpeg', 'video/mp4') */ mimeType: string; /** File size in bytes */ size: number; /** Alternative text for accessibility */ altText?: string; /** Target folder ID (null for root) */ folderId?: string | null; } /** * Input for updating media metadata */ interface UpdateMediaInput { filename?: string; altText?: string | null; caption?: string | null; tags?: string[]; folderId?: string | null; } /** * Media type for filtering */ type MediaType = "image" | "video" | "audio" | "document" | "other"; /** * Options for listing media files */ interface ListMediaOptions { /** Page number (1-indexed) */ page?: number; /** Items per page */ limit?: number; /** Search query (filename, altText) */ search?: string; /** Filter by media type (image, video, audio, document) */ type?: MediaType; /** Filter by folder ID (null or 'root' for root folder) */ folderId?: string; /** Sort field */ sortBy?: "uploadedAt" | "filename" | "size"; /** Sort direction */ sortOrder?: "asc" | "desc"; } /** * Media folder */ interface MediaFolder { id: string; name: string; description?: string | null; color?: string | null; icon?: string | null; parentId?: string | null; createdBy: string; createdAt: Date; updatedAt: Date; } /** * Input for creating a folder */ interface CreateFolderInput { name: string; description?: string; color?: string; icon?: string; parentId?: string | null; } /** * Input for updating a folder */ interface UpdateFolderInput { name?: string; description?: string; color?: string; icon?: string; parentId?: string | null; } /** * Folder contents (subfolders + files) */ interface FolderContents { folder: MediaFolder; subfolders: MediaFolder[]; files: MediaFile[]; breadcrumbs: Array<{ id: string; name: string; }>; } /** * Bulk-by-id operation result for media (e.g. bulkDelete). * * Phase 4.5: redesigned to mirror the collection-domain shape so the * media-bulk dispatcher can hand it directly to respondBulk without a * second translation pass. Successes carry minimal `{id}` records for * delete (the file is gone); failures carry canonical NextlyErrorCode * + public-safe message per spec §13.8. * * Generic over T so a future bulk-update-media op could carry full * MediaFile records on success without changing the shape. */ interface BulkOperationResult { successes: T[]; failures: Array<{ /** Identifier of the media item that failed. */ id: string; /** Canonical NextlyErrorCode value. */ code: string; /** Public-safe message (no identifier or value echo). */ message: string; }>; total: number; successCount: number; failedCount: number; } /** * Bulk-upload operation result (positional, no client ids). * * Phase 4.5: distinct from BulkOperationResult because failed uploads * have no id by construction. Failures are positional (`index`, * `filename`); successes carry the newly-created MediaFile (or whatever * the upload service returns). */ interface BulkUploadOperationResult { successes: T[]; failures: Array<{ /** Positional index in the input payload. */ index: number; /** Filename from the input. UX context only, not an identifier. */ filename: string; /** Canonical NextlyErrorCode value. */ code: string; /** Public-safe message. */ message: string; }>; total: number; successCount: number; failedCount: number; } /** * MediaService - Unified service for media file and folder operations * * This service provides a clean API for both media file operations (upload, delete, etc.) * and folder management (create, organize, move files). It follows the new service layer * architecture with: * * - Exception-based error handling using NextlyError * - RequestContext for user/locale context * - PaginatedResult for list operations * - Constructor injection for storage and image processor * * Internally delegates to the legacy MediaService and MediaFolderService for the actual * implementation, converting their result-shape return format to throw-based NextlyError. * * @example * ```typescript * import { MediaService, NextlyError } from 'nextly'; * * const service = new MediaService(legacyMediaService, legacyFolderService, storage, imageProcessor); * * // Upload a file * const file = await service.upload({ * buffer: fileBuffer, * filename: 'photo.jpg', * mimeType: 'image/jpeg', * size: 1024000, * }, context); * * // Create a folder * const folder = await service.createFolder({ name: 'Photos' }, context); * * // Move file to folder * await service.moveToFolder(file.id, folder.id, context); * * // Error handling * try { * const media = await service.findById('nonexistent', context); * } catch (error) { * if (NextlyError.isNotFound(error)) { * console.log(error.code); // 'NOT_FOUND' * console.log(error.statusCode); // 404 * } * } * ``` */ /** * MediaService - Unified service for media files and folders * * Provides complete media management with: * * - Exception-based error handling (throws NextlyError) * - Type-safe RequestContext * - PaginatedResult for list operations * - Storage provider injection for testability * - Logging support */ declare class MediaService { private readonly legacyMediaService; private readonly legacyFolderService; private readonly storageOrGetter; private readonly imageProcessor; private readonly uploadValidator; /** * Whether sanitized SVGs are persisted with `Content-Disposition: attachment`. * Mirrors `UploadService`'s `svgCsp` flag — sourced from * `config.security.uploads.svgCsp` (default `true`). */ private readonly svgCsp; private readonly logger; /** * Retention passes offered after a write. The shared runner carries both — * the webhook outbox and the audit trails — each on its own window and its * own gate, and decides which are configured. * * Absent only when NEITHER has anything to prune: an install with webhook * retention off and audit retention on still gets one. A construction site * that forwards a single policy leaves that domain unpruned rather than * failing, so both belong wherever this is built. */ private readonly retentionRunner?; /** * Shared post-response drain fast path. A media write commits its outbox * row inside the DB transaction; `offer()` then schedules the immediate * drain so subscribers are notified without waiting for the periodic * scheduled drain. Absent only when webhooks were never registered. */ private readonly fastDrainScheduler?; constructor(legacyMediaService: MediaService$1, legacyFolderService: MediaFolderService, storageOrGetter: IStorageAdapter | (() => IStorageAdapter | null) | null, imageProcessor: ImageProcessor, uploadValidator: UploadValidator, /** * Whether sanitized SVGs are persisted with `Content-Disposition: attachment`. * Mirrors `UploadService`'s `svgCsp` flag — sourced from * `config.security.uploads.svgCsp` (default `true`). */ svgCsp?: boolean, logger?: Logger, /** * Retention passes offered after a write. The shared runner carries both — * the webhook outbox and the audit trails — each on its own window and its * own gate, and decides which are configured. * * Absent only when NEITHER has anything to prune: an install with webhook * retention off and audit retention on still gets one. A construction site * that forwards a single policy leaves that domain unpruned rather than * failing, so both belong wherever this is built. */ retentionRunner?: RetentionRunner | undefined, /** * Shared post-response drain fast path. A media write commits its outbox * row inside the DB transaction; `offer()` then schedules the immediate * drain so subscribers are notified without waiting for the periodic * scheduled drain. Absent only when webhooks were never registered. */ fastDrainScheduler?: WebhookFastDrainScheduler | undefined); private static readonly WRITE_PATH_PRUNE_BATCHES; /** * The post-write side effects, run after a media write that appended an * outbox event. The drain fast path goes first so the post-response * `after()` callback is scheduled promptly (it adds no latency); the * retention pass follows. Both absorb their own failures (`maybeRun` never * throws, `offer` only registers the callback), so this never turns a * committed media write into an error. Mirrors the collection write path. */ private afterWrite; /** * Get the storage adapter (supports both direct reference and getter function) * This allows for late-registration of storage plugins */ private getStorage; /** * Ensure storage is configured before media operations * @throws NextlyError(VALIDATION_ERROR) if storage is not configured. */ private ensureStorageConfigured; /** * Upload a media file * * @param input - Upload data (buffer, filename, mimeType, size) * @param context - Request context with user info * @returns Uploaded media file * @throws NextlyError if upload fails (e.g., invalid file, size limit). * * @example * ```typescript * const file = await service.upload({ * buffer: fileBuffer, * filename: 'photo.jpg', * mimeType: 'image/jpeg', * size: 1024000, * }, context); * ``` */ upload(input: UploadMediaInput, context: RequestContext$2, actor?: RequestActor): Promise; /** * Find a media file by ID * * @param mediaId - Media file ID * @param context - Request context * @returns Media file data * @throws NextlyError(NOT_FOUND) if the file doesn't exist. */ findById(mediaId: string, _context: RequestContext$2): Promise; /** * List media files with pagination and filtering * * @param options - Query options (pagination, search, filters) * @param context - Request context * @returns Paginated list of media files */ listMedia(options: ListMediaOptions | undefined, _context: RequestContext$2): Promise>; /** * Update media file metadata * * @param mediaId - Media file ID * @param input - Update data * @param context - Request context * @returns Updated media file * @throws NextlyError if update fails. */ update(mediaId: string, input: UpdateMediaInput, context: RequestContext$2, actor?: RequestActor): Promise; /** * Delete a media file * * @param mediaId - Media file ID * @param context - Request context * @throws NextlyError if deletion fails. */ delete(mediaId: string, context: RequestContext$2, actor?: RequestActor): Promise; /** * Upload multiple files. * * Phase 4.5: returns BulkUploadOperationResult. Successes * carry the newly-created MediaFile records (with assigned ids); failures * are positional (no id, since the upload never made it that far) and * carry canonical NextlyErrorCode + public-safe message. * * @param inputs - Array of files to upload * @param context - Request context * @returns Bulk-upload operation result with full MediaFile on success */ bulkUpload(inputs: UploadMediaInput[], context: RequestContext$2, actor?: RequestActor): Promise>; /** * Delete multiple media files. * * Phase 4.5: returns BulkOperationResult<{id}>. Successes carry the * deleted ids; failures are id-keyed with canonical NextlyErrorCode. * * @param mediaIds - Array of media IDs to delete * @param context - Request context * @returns Bulk operation result with id-only successes */ bulkDelete(mediaIds: string[], context: RequestContext$2, actor?: RequestActor): Promise>; /** * Move a media file to a folder * * @param mediaId - Media file ID * @param folderId - Target folder ID (null for root) * @param context - Request context * @throws NextlyError if move fails. */ moveToFolder(mediaId: string, folderId: string | null, context: RequestContext$2, actor?: RequestActor): Promise; /** * Get storage type * * @returns Storage type string ('local', 'vercel-blob', 's3', 'r2'), or 'none' if not configured */ getStorageType(): string; /** * Check if storage is configured */ hasStorage(): boolean; /** * Create a new folder * * @param input - Folder data * @param context - Request context * @returns Created folder * @throws NextlyError if creation fails. */ createFolder(input: CreateFolderInput, context: RequestContext$2): Promise; /** * Find a folder by ID * * @param folderId - Folder ID * @param context - Request context * @returns Folder data * @throws NextlyError(NOT_FOUND) if the folder doesn't exist. */ findFolderById(folderId: string, _context: RequestContext$2): Promise; /** * List root folders * * @param context - Request context * @returns List of root folders */ listRootFolders(_context: RequestContext$2): Promise; /** * List subfolders of a folder * * @param parentId - Parent folder ID * @param context - Request context * @returns List of subfolders */ listSubfolders(parentId: string, _context: RequestContext$2): Promise; /** * Get folder contents (subfolders + files) * * @param folderId - Folder ID (null for root) * @param context - Request context * @returns Folder contents with breadcrumbs */ getFolderContents(folderId: string | null, _context: RequestContext$2): Promise; /** * Update a folder * * @param folderId - Folder ID * @param input - Update data * @param context - Request context * @returns Updated folder * @throws NextlyError if update fails. */ updateFolder(folderId: string, input: UpdateFolderInput, _context: RequestContext$2): Promise; /** * Delete a folder * * @param folderId - Folder ID * @param deleteContents - Whether to delete contents (default: false) * @param context - Request context * @throws NextlyError if deletion fails. */ deleteFolder(folderId: string, deleteContents: boolean | undefined, _context: RequestContext$2): Promise; /** * Check if a file is an image * * @param mimeType - MIME type to check * @returns True if the MIME type is an image type */ isImage(mimeType: string): boolean; /** * Validate an image buffer * * @param buffer - File buffer * @returns True if buffer is a valid image */ validateImage(buffer: Buffer): Promise; /** * Get image dimensions * * @param buffer - Image buffer * @returns Dimensions or null if not an image */ getImageDimensions(buffer: Buffer): Promise<{ width: number; height: number; } | null>; /** * Map legacy media data to MediaFile type */ private mapToMediaFile; /** * Map legacy folder data to MediaFolder type */ private mapToMediaFolder; /** * Convert legacy result-shape responses (`{ success, statusCode, message, data }`) * from the underlying MediaService/MediaFolderService into a NextlyError. * * The legacy `message` field is treated as operator-only context — it * frequently contains driver text or specific identifiers, neither of * which §13.8 allows on the public message. The legacy message is stored * on logContext and the factory's canonical public message is used. */ /** * Rebuild the error a failed media result came from. * * Through the shared converter, so a media failure and a collection failure * with the same meaning answer with the same code. This kept its own status * table -- the third in the codebase -- and it disagreed with the others on * 409 and 422 while its parameter type omitted `code`, `messageKey` and * `publicData` entirely, so every media failure arrived stripped of them. * * `logContext` carries what the caller knows and the envelope does not: which * entity, and which id. Operator-only; it never reaches the wire. */ private mapLegacyErrorToNextlyError; /** * Convert the simple legacy result-shape (no `data` field) into a * NextlyError. Thin adapter over the full mapper. */ private mapSimpleErrorToNextlyError; } /** * Allowed field types for user custom fields: the canonical flat scalars * plus two user-surface-only types. `url` and `phone` are deliberately NOT * canonical field types — a collection cannot declare them — so they can * never reach the schema pipeline. Both store as text; their meaning is * validation. */ type UserFieldType = "text" | "textarea" | "number" | "email" | "url" | "phone" | "select" | "radio" | "checkbox" | "date"; /** The canonical scalar types shared with collections. */ type CanonicalUserFieldType = Exclude; /** Shared shape of the user-surface-only field configs. */ interface UserSurfaceFieldBase { /** Column name on `user_ext` and key on the user object. */ name: string; /** Human label shown in the admin. */ label?: string; /** Whether a value is required. */ required?: boolean; /** Default value applied at the application layer. */ defaultValue?: string; /** Maximum string length; also sizes newly created varchar columns. */ maxLength?: number; /** Minimum string length. */ minLength?: number; /** Admin presentation options. */ admin?: { placeholder?: string; description?: string; }; } /** A validated web address stored as text. */ interface UserUrlFieldConfig extends UserSurfaceFieldBase { type: "url"; } /** A phone number stored as text. */ interface UserPhoneFieldConfig extends UserSurfaceFieldBase { type: "phone"; } /** * Marks a declaration as belonging to a plugin type rather than a built-in. * * `string & {}` accepts every literal, so an open arm in the union has nothing * structural to tell it apart from the built-in arms: a malformed * `{ type: "select" }` missing its `options` would satisfy the open arm and * lose the error its own shape raises. A symbol nothing else can name is what * keeps the two apart, and `pluginUserField()` is the only thing that sets it, * so reaching the open arm is deliberate rather than a shape falling through * to it. */ declare const pluginUserFieldBrand: unique symbol; /** * A field whose type a plugin contributed and opted into the `users` surface. * * The type id is not knowable here — it belongs to whichever plugin is * installed — so the arm is open on both the token and the options the type * declares for itself. Runtime validation already accepts these; without an arm * for them the authoring type did not, so a code-defined plugin user field * failed the app's type check unless it was cast. * * Written with `pluginUserField()` rather than as a bare object literal. */ interface UserPluginFieldInput extends UserSurfaceFieldBase { type: string & {}; /** * Options belonging to the field's own plugin type. * * Optional, because a type may take none, and one whose option names collide * with nothing the built-in shape declares may write them directly on the * field instead. Requiring an empty container to satisfy the type would make * this narrower than what the runtime accepts. */ pluginOptions?: Record; [option: string]: unknown; } /** The same declaration once `pluginUserField()` has marked it. */ interface UserPluginFieldConfig extends UserPluginFieldInput { readonly [pluginUserFieldBrand]: true; } /** * Declare a user field whose type a plugin contributed. * * A built-in token is refused here. Marking one would put it on the open arm, * where its own shape is never checked — `{ type: "select" }` would satisfy * `UserFieldConfig` without the `options` a select requires, which is the very * thing the marker exists to prevent. * * @example * ```ts * users: { * fields: [ * { name: "company", type: "text" }, * pluginUserField({ name: "score", type: "star-rating" }), * ], * } * ``` */ declare function pluginUserField(field: T & (T["type"] extends UserFieldType ? { type: "this is a built-in field type; declare it directly so its own shape is checked"; } : unknown)): UserPluginFieldConfig; /** * A field configuration restricted to user-allowed types. */ type UserFieldConfig = Extract | UserUrlFieldConfig | UserPhoneFieldConfig | UserPluginFieldConfig; /** * Admin panel options for user management. */ interface UserAdminOptions { /** * Which custom fields to display as columns in the user list table. * Field names reference the `name` property of UserFieldConfig. * @example ['company', 'department', 'phoneNumber'] */ listFields?: string[]; /** * Group label for custom fields section in create/edit forms. * @default 'Additional Information' */ group?: string; } /** * User configuration for extending the built-in user model. */ interface UserConfig { /** * Custom fields to add to the user model. * These are stored in a separate `user_ext` table with proper typed columns. * Only scalar field types are supported: text, textarea, number, email, * select, radio, checkbox, date. * * @example * fields: [ * text({ name: 'phoneNumber', label: 'Phone Number' }), * text({ name: 'company', label: 'Company' }), * select({ name: 'department', options: [...] }), * ] */ fields?: UserFieldConfig[]; /** * Admin panel configuration for user management. */ admin?: UserAdminOptions; } /** * Dialect-Agnostic Type Definitions for User Field Definitions * * These types define the structure for the `user_field_definitions` table * used to manage custom user field metadata. Fields can be sourced from * `defineConfig()` (code) or created via the Admin UI (ui). * * @module schemas/user-field-definitions/types * @since 1.0.0 */ /** * Origin of a user field definition. * * - `code`: Synced from `defineConfig()` `users.fields` — read-only in admin UI * - `ui`: Created via the admin Settings > User Fields tab — fully editable */ type UserFieldSource = "code" | "ui"; /** * Insert type for creating a new user field definition. * * Contains all required and optional fields for inserting a field definition * into the `user_field_definitions` table. Fields with defaults (like * `required`, `isActive`, `sortOrder`) are optional on insert. * * @example * ```typescript * const newField: UserFieldDefinitionInsert = { * name: 'company', * label: 'Company', * type: 'text', * source: 'ui', * placeholder: 'Enter company name', * }; * ``` */ interface UserFieldDefinitionInsert { /** * Unique field name used as the column name in `user_ext` table. * Must be a valid identifier (alphanumeric + underscores). * @example 'phoneNumber' */ name: string; /** * Human-readable label displayed in the admin UI. * @example 'Phone Number' */ label: string; /** * Field type determining the input component and column type. A built-in * scalar (text, textarea, number, email, url, phone, select, radio, checkbox, * date) or a plugin-contributed type that opted into the users surface; the * `(string & {})` arm admits the latter while keeping built-in autocomplete. */ type: UserFieldType | (string & {}); /** * Whether this field is required when creating/updating a user. * @default false */ required?: boolean; /** * Default value for this field when creating a new user. * Stored as a string regardless of type (parsed at runtime). */ defaultValue?: string | null; /** * Available options for `select` and `radio` field types. * Each option has a `label` (display text) and `value` (stored value). * Should be `null` for non-select/radio types. */ options?: { label: string; value: string; }[] | null; /** * Whether a `select` field stores multiple values. Fixed at creation — * it decides the backing column's type and cannot change afterwards. */ hasMany?: boolean | null; /** Minimum string length for text/textarea values; null means unconstrained. */ minLength?: number | null; /** * Maximum string length for text/textarea values; null means * unconstrained. Also sizes newly created varchar columns. */ maxLength?: number | null; /** Minimum numeric value for number fields; null means unconstrained. */ minValue?: number | null; /** Maximum numeric value for number fields; null means unconstrained. */ maxValue?: number | null; /** * Placeholder text shown in the input field. * @example 'Enter your phone number' */ placeholder?: string | null; /** * Help text / description shown below the input field. * @example 'Your company phone number including country code' */ description?: string | null; /** * Sort order for display in the admin UI. * Lower numbers appear first. * @default 0 */ sortOrder?: number; /** * Origin of this field definition. * - `code`: Synced from `defineConfig()` — read-only in admin UI * - `ui`: Created via admin Settings — fully editable * @default 'ui' */ source?: UserFieldSource; /** * Whether this field is currently active. * Inactive fields are stored but not rendered in forms or included in queries. * @default true */ isActive?: boolean; } /** * Full record type for a user field definition. * * Extends `UserFieldDefinitionInsert` with all required fields that are * set by the database (id, timestamps) or have default values. * * @example * ```typescript * const field: UserFieldDefinitionRecord = { * id: 'uuid-789', * name: 'department', * label: 'Department', * type: 'select', * required: false, * defaultValue: null, * options: [ * { label: 'Engineering', value: 'engineering' }, * { label: 'Marketing', value: 'marketing' }, * ], * placeholder: null, * description: 'The department this user belongs to', * sortOrder: 1, * source: 'ui', * isActive: true, * createdAt: new Date(), * updatedAt: new Date(), * }; * ``` */ interface UserFieldDefinitionRecord extends UserFieldDefinitionInsert { /** Unique identifier (UUID or CUID). */ id: string; /** Whether this field is required (required on record). */ required: boolean; /** Default value (required on record, nullable). */ defaultValue: string | null; /** Options for select/radio (required on record, nullable). */ options: { label: string; value: string; }[] | null; /** * Options belonging to the field's own plugin type, carried verbatim. * * The record is otherwise built from a fixed list of core properties, which * drops anything a plugin type declared for itself — leaving codegen to emit * the broad fallback for a type whose options would have narrowed it. * * Set only on the record `generate-types` builds from a code-defined config, * and read only while generating: the user-field tables have no column for * it, so a record loaded from the database never carries one. Optional for * that reason rather than because a type may omit its options. */ pluginOptions?: Record | null; /** Multi-value flag for select fields (required on record, nullable). */ hasMany: boolean | null; /** Minimum string length (required on record, nullable). */ minLength: number | null; /** Maximum string length (required on record, nullable). */ maxLength: number | null; /** Minimum numeric value (required on record, nullable). */ minValue: number | null; /** Maximum numeric value (required on record, nullable). */ maxValue: number | null; /** Placeholder text (required on record, nullable). */ placeholder: string | null; /** Description / help text (required on record, nullable). */ description: string | null; /** Sort order (required on record). */ sortOrder: number; /** Field source (required on record). */ source: UserFieldSource; /** Whether this field is active (required on record). */ isActive: boolean; /** When the field definition was created. */ createdAt: Date; /** When the field definition was last updated. */ updatedAt: Date; } /** * User Field Definition Service * * CRUD operations for managing user field definitions stored in the * `user_field_definitions` table. Supports both UI-sourced fields * (fully editable in admin) and code-sourced fields (synced from * `defineConfig()`, read-only in admin). * * Write operations (`updateField`, `deleteField`) reject code-sourced * fields with a business rule error — code fields should be modified * in `defineConfig()` instead. * * @module services/users/user-field-definition-service * @since 1.0.0 */ /** * Input for creating a new user field definition. * Extends UserFieldDefinitionInsert (all required + optional fields). */ type CreateUserFieldDefinitionInput = UserFieldDefinitionInsert; /** * Input for updating an existing user field definition. * All fields are optional — only provided fields are updated. * Note: `source` cannot be changed after creation. */ interface UpdateUserFieldDefinitionInput { name?: string; label?: string; type?: string; required?: boolean; defaultValue?: string | null; options?: { label: string; value: string; }[] | null; /** * Accepted only when echoed back unchanged — the flag decides the backing * column's type, which cannot change after creation. */ hasMany?: boolean | null; minLength?: number | null; maxLength?: number | null; minValue?: number | null; maxValue?: number | null; placeholder?: string | null; description?: string | null; sortOrder?: number; isActive?: boolean; } declare class UserFieldDefinitionService extends BaseService { /** Dialect-specific Drizzle table for user_field_definitions (resolved at construction) */ private userFieldDefinitions; constructor(adapter: DrizzleAdapter, logger: Logger); /** * Reject a name that cannot back a `user_ext` column. * * A custom field's name becomes both a column identifier and a key on the * user object, where it is assigned over the built-ins — so a field named * `email` or `id` displaces the real one rather than sitting beside it. * `defineConfig()` applies the same check to code-defined fields. */ private assertUsableName; /** * Reject a type that has no `user_ext` column representation. */ private assertUsableType; /** * Reject a change to an existing field's name or type. * * Both identify the backing `user_ext` column, and the schema reconciler * only ever adds columns: a rename leaves the old column and its data * stranded under the old name, and a type change leaves the column at its * original type. Values echoed back unchanged are accepted so that clients * can send a whole field back without special-casing these two keys. */ private assertIdentityUnchanged; /** * Create a new user field definition. * * If `sortOrder` is not provided, it defaults to one higher than * the current maximum sort order (appending to the end). * * @throws NextlyError(VALIDATION) when the name or type cannot back a column * @throws NextlyError(DUPLICATE) on unique constraint violation (duplicate name) */ createField(data: CreateUserFieldDefinitionInput): Promise; /** * Get a single user field definition by ID. * * §13.8: public message is generic; entity name + id flow through logContext. * * @throws NextlyError(NOT_FOUND) if field definition doesn't exist */ getField(id: string): Promise; /** * List all user field definitions, ordered by sort order (ascending), * then by creation date (ascending) as a tiebreaker. */ listFields(): Promise; /** * Update an existing user field definition. * * Only UI-sourced fields can be updated. Code-sourced fields * must be modified in `defineConfig()`. * * Note: `source` cannot be changed after creation, but `name` can be updated. * * @throws NextlyError(NOT_FOUND) if field definition doesn't exist * @throws NextlyError(BUSINESS_RULE_VIOLATION) if field is code-sourced */ updateField(id: string, data: UpdateUserFieldDefinitionInput): Promise; /** * Delete a user field definition. * * Only UI-sourced fields can be deleted. Code-sourced fields * must be removed from `defineConfig()`. * * @throws NextlyError(NOT_FOUND) if field definition doesn't exist * @throws NextlyError(BUSINESS_RULE_VIOLATION) if field is code-sourced */ deleteField(id: string): Promise; /** * Reorder field definitions by updating `sortOrder` based on * the position of each field ID in the provided array. * * Field IDs not in the array keep their current sort order. * Uses a transaction for atomicity. * * @param fieldIds - Array of field IDs in the desired order * @returns Updated list of all field definitions */ reorderFields(fieldIds: string[]): Promise; /** * Sync code-defined fields from `defineConfig()` into the * `user_field_definitions` table. * * - Upserts code fields with `source = 'code'` * - Deletes stale `source = 'code'` rows no longer in config * - Code fields get `sortOrder` based on array index (0, 1, 2...) * - Called on startup (dev-server boot / `nextly db:sync`) * - Idempotent — safe to run on every startup * * @param codeFields - Fields from `defineConfig().users.fields` */ syncCodeFields(codeFields: { name: string; [key: string]: unknown; }[]): Promise; /** * Get merged field list (code + UI) for schema generation. * * Returns all active field definitions ordered by sort order, * combining both code-synced and UI-created fields. */ getMergedFields(): Promise; /** * Get the next available sort order value (max + 1). * Returns 0 if no fields exist. */ private getNextSortOrder; } /** * UserExtSchemaService * * Generates database schemas for the `user_ext` table that stores custom user fields. * Handles SQL migration generation, Drizzle TypeScript schema code generation, * runtime Drizzle table object creation, and schema hashing for change detection. * * The `user_ext` table extends the built-in `users` table with custom fields defined * via `defineConfig({ users: { fields: [...] } })` or the admin UI. * * Base columns: * - `id`: text PK * - `user_id`: text FK → users.id (unique, NOT NULL, cascade delete) * - `created_at`, `updated_at`: timestamps * * Supports PostgreSQL, MySQL, and SQLite dialects. * * @module services/users/user-ext-schema-service * @since 1.0.0 * * @example * ```typescript * const schemaService = new UserExtSchemaService('postgresql'); * * // Generate SQL migration for the user_ext table * const sql = schemaService.generateMigrationSQL(userConfig.fields); * * // Generate Drizzle TypeScript schema code * const code = schemaService.generateSchemaCode(userConfig.fields); * * // Generate runtime Drizzle table object for querying * const table = schemaService.generateRuntimeSchema(userConfig.fields); * * // Compute schema hash for change detection * const hash = schemaService.computeSchemaHash(userConfig.fields); * ``` */ /** * Runtime-generated Drizzle table — columns are dynamic so we cannot * express the full shape statically. Property access (e.g., `table.user_id`) * uses the `Record` intersection. */ type DrizzleRuntimeTable = Table & Record; type SupportedDialect = "postgresql" | "mysql" | "sqlite"; declare class UserExtSchemaService { private readonly dialect; private readonly q; private readonly fieldDefService?; private readonly logger?; /** Cached merged fields from both code and UI sources */ private mergedFields; constructor(dialect?: SupportedDialect, fieldDefService?: UserFieldDefinitionService, logger?: Logger); /** * Load merged fields from both code (`defineConfig()`) and UI * (`user_field_definitions` table) sources. * * Must be called after `syncCodeFields()` so the database reflects * the latest code-defined fields. The result is cached internally * and exposed via `getMergedFieldConfigs()`. * * If no `UserFieldDefinitionService` was provided, this is a no-op. */ loadMergedFields(): Promise; /** * Reload merged fields from the database, clearing any stale cache. * * Called after field CRUD operations (create, update, delete) to * ensure the in-memory merged fields reflect the latest DB state. */ reloadMergedFields(): Promise; /** * Ensure the `user_ext` table exists and has columns for all * current merged fields. * * Executes CREATE TABLE IF NOT EXISTS followed by ALTER TABLE * ADD COLUMN for each field. Safe to call multiple times (idempotent). * * @param db - Drizzle database instance for executing raw SQL */ ensureUserExtSchema(db: unknown): Promise; /** * Get the cached merged field configs. * * Returns field configs from both code and UI sources (loaded via * `loadMergedFields()`). Returns an empty array if not yet loaded * or if no fields exist. */ getMergedFieldConfigs(): UserFieldConfig[]; /** * Check whether merged fields have been loaded and contain at least one field. */ hasMergedFields(): boolean; /** * Convert a `UserFieldDefinitionRecord` (DB row) to a `UserFieldConfig` * compatible with the field type system. * * Maps DB record properties to the discriminated union shape expected * by schema generation, runtime table creation, and field type guards. */ private convertRecordToFieldConfig; /** * Generate SQL migration for creating the `user_ext` table. * * Creates a table with: * - Base columns: id, user_id (FK → users.id, unique) * - Field columns: generated from UserFieldConfig definitions * - Timestamp columns: created_at, updated_at * - Unique index on user_id * * @param fields - User field definitions * @returns SQL migration string */ generateMigrationSQL(fields: UserFieldConfig[]): string; /** * Generate ALTER TABLE migration for updating the `user_ext` table. * * Detects added, removed, and modified fields and generates * dialect-specific ALTER TABLE statements. * * @param oldFields - Previous field definitions * @param newFields - New field definitions * @returns SQL migration string */ generateAlterTableMigration(oldFields: UserFieldConfig[], newFields: UserFieldConfig[]): string; /** * Generate DROP TABLE migration for the `user_ext` table. * * @returns Object with SQL string and migration file name */ generateDropTableMigration(): { migrationSQL: string; migrationFileName: string; }; /** * Generate a Drizzle table object at runtime for querying user_ext data. * * Used for UI-created custom user fields that don't have pre-compiled schemas. * The returned table object can be passed to Drizzle queries. * * @param fields - User field definitions * @returns A Drizzle table object * * @example * ```typescript * const table = schemaService.generateRuntimeSchema(userConfig.fields); * const rows = await db.select().from(table).where(eq(table.user_id, userId)); * ``` */ generateRuntimeSchema(fields: UserFieldConfig[]): DrizzleRuntimeTable; private generatePostgresSchema; private generateMySQLSchema; private generateSQLiteSchema; /** * Generate TypeScript/Drizzle schema code for the `user_ext` table. * * Produces a complete TypeScript file with imports, table definition, * indexes, and inferred types. * * @param fields - User field definitions * @returns TypeScript source code string */ generateSchemaCode(fields: UserFieldConfig[]): string; /** * Compute a deterministic hash for the given user fields. * * Reuses the existing schema hash utility that normalizes fields, * sorts keys, and produces a SHA-256 hash. Used for change detection * to determine when migrations are needed. * * @param fields - User field definitions * @returns 64-character hex string (SHA-256 hash) */ computeSchemaHash(fields: UserFieldConfig[]): string; private generateColumnSQL; /** * The user-surface-only types (url, phone) store as text; they are not * canonical field types, so the shared guards never match them. */ private isUserSurfaceTextField; /** * Whether a user field backs a column: either a canonical data field or * one of the user-surface text types the shared guard cannot know about. */ private isUserDataField; private getColumnType; /** * The declared storage primitive of a users-surface plugin field, or null for * a built-in / unregistered / non-users type. The column type across the DDL, * runtime schema, and code-gen paths all key off this so they never diverge. */ private pluginUserStorage; private mapFieldToPostgresColumn; private mapFieldToMySQLColumn; private mapFieldToSQLiteColumn; private mapFieldToDrizzleCode; private mapFieldToPostgresCode; private mapFieldToMySQLCode; private mapFieldToSQLiteCode; private getDialectConfig; private collectRequiredImports; private generateBaseColumnsCode; private generateTimestampColumnsCode; private isFieldModified; private buildFieldMap; /** * Map a field type to the built-in category whose SQL default formatting * matches its column. A plugin user field is keyed by its storage primitive * (so a `number`-storage plugin field defaults like a number column, not * text); built-ins pass through unchanged. */ private effectiveDefaultType; /** A valid empty-JSON default literal for the current dialect. */ private emptyJsonDefault; /** Coerce a stored default (which may be a string like "false") to boolean. */ private toBooleanDefault; private getDefaultValueForType; private formatDefaultValue; private toSnakeCase; } /** * UserAccountService - Profile, Account, and Password operations * * Handles current user profile updates, OAuth account management, * and password-related operations. * * @example * ```typescript * const accountService = new UserAccountService(adapter, logger); * * const profile = await accountService.getCurrentUser(userId); * await accountService.updateCurrentUser(userId, { name: 'New Name' }); * const accounts = await accountService.getAccounts(userId); * ``` */ /** * Response type for single user operations. * Post-migration: data is returned directly (callers no longer destructure * `.success`/`.data`); failures throw NextlyError. */ type GetUserResponse$1 = MinimalUser$1; /** * Response type for account list operations. * Post-migration: an empty array is returned when no accounts are linked, * and DB failures throw NextlyError. The 404-when-empty behavior was an * envelope quirk and not a real precondition violation. */ type GetAccountsResponse = UserAccount[]; /** * Response type for unlink account operation */ type UnlinkAccountResult = { ok: true; } | { ok: false; status: number; error: string; }; declare class UserAccountService extends BaseService { private queryService; /** * Creates a new UserAccountService instance. * * @param adapter - Database adapter for multi-database support * @param logger - Logger instance */ constructor(adapter: DrizzleAdapter, logger: Logger); /** * Get the current user's profile (delegates to getUserById). * * @throws NextlyError(NOT_FOUND) when the user does not exist. */ getCurrentUser(userId: number | string): Promise; /** * Update the current user's profile (name and image only). * * @throws NextlyError(NOT_FOUND) when the user does not exist * (propagated from queryService.getUserById). * @throws NextlyError(DUPLICATE) on unique-violation collisions (e.g. * email already belongs to another user). * @throws NextlyError on other DB errors via fromDatabaseError. */ updateCurrentUser(userId: number | string, changes: { name?: string; image?: string; }): Promise; /** * Update a user's password hash. * * @throws NextlyError(NOT_FOUND) when the user does not exist. * @throws NextlyError on DB errors via fromDatabaseError. */ updatePasswordHash(userId: number | string, passwordHash: string): Promise; /** * Check if a user has a password set */ hasPassword(userId: number | string): Promise; /** * Get a user's password hash by ID */ getUserPasswordHashById(userId: number | string): Promise; /** * Get all OAuth accounts linked to a user. * * Returns an empty array when no accounts are linked. The pre-migration * "404 No accounts linked to this user" was an envelope quirk — having * zero linked accounts is a normal state for password-only users, not an * error condition. Callers that need the count should check `.length`. * * @throws NextlyError on DB errors via fromDatabaseError. */ getAccounts(userId: number | string): Promise; /** * Delete a specific OAuth account for a user */ deleteUserAccount(userId: number | string, provider: string, providerAccountId: string): Promise; /** * Unlink an OAuth account from a user (with safety check for last auth method). * * Returns an `UnlinkAccountResult` discriminated union rather than throwing * for the safety-check failure / not-found cases. These are caller-facing * decisions (e.g. show a confirmation prompt, render a 400 response) where * a thrown error would force the caller to write try/catch around a * predictable control-flow branch. Real DB faults still throw NextlyError. */ unlinkAccountForUser(userId: number | string, provider: string, providerAccountId: string): Promise; } /** * UserMutationService - Write operations for users * * Handles user creation, updates, and deletion with validation, * role assignment, and email service integration. * * This service uses the database adapter pattern for multi-database support * (PostgreSQL, MySQL, SQLite). For complex queries, it uses direct Drizzle * access via the compatibility layer until the adapter is enhanced. * * @example * ```typescript * const mutationService = new UserMutationService(adapter, logger); * * const newUser = await mutationService.createLocalUser({ email: 'user@example.com', name: 'John' }); * await mutationService.updateUser(userId, { name: 'Jane' }); * await mutationService.deleteUser(userId); * ``` */ /** * The single capability the user write paths need from the webhook fast-path * drain: a synchronous, self-gating kick that runs a bounded delivery after the * response. Declared as this narrow interface — which `WebhookFastDrainScheduler` * satisfies — rather than the concrete scheduler so the dependency is exactly * the method called, and a test can supply a spy without reconstructing the * scheduler's private state. */ interface WebhookDrainOffer { offer(): void; } /** * The single capability the user write paths need from the webhook retention * runner: a bounded, self-gating prune offered after a committed write. Narrow * (which `RetentionRunner` satisfies) for the same reason as * {@link WebhookDrainOffer}. */ interface WebhookRetentionOffer { maybeRun(maxBatches?: number): Promise; } /** * Data for creating a new local user. * Index signature allows custom field values from UserConfig.fields to pass through. */ interface CreateLocalUserData { email: string; name: string; image?: string | null; /** * Omit (or leave empty) to create the account in invite mode: no credential * is set and a single-use set-password link is returned for the admin to * deliver. Provide a password to set the credential directly. */ password?: string | null; roles?: string[]; isActive?: boolean; /** * Force the user to replace this password on first sign-in (ASVS 6.4.1). * Set by the admin create path when an admin types a password for someone * else; NOT derived from password presence, so self-registration and the * setup flow (where the person chooses their own password) never trip it. */ mustChangePassword?: boolean; /** Custom field values from user_ext */ [key: string]: unknown; } /** * Data for updating an existing user. * Index signature allows custom field values from UserConfig.fields to pass through. */ interface UpdateUserData { email?: string; name?: string; image?: string; password?: string | null; emailVerified?: Date | null; roles?: string[]; isActive?: boolean; sendWelcomeEmail?: boolean; /** Custom field values from user_ext */ [key: string]: unknown; } /** * The set-password link handed back when a user is created in invite mode * (no password): the copyable link is the artifact, and delivery by email is * an optional convenience on top of it. */ interface InviteArtifact { /** The copyable set-password link to give to the new user. */ link: string; /** When the link stops working. */ expiresAt: Date; } /** * Response type for user mutation operations. * * Post-migration (PR 4): no `success`/`statusCode`/`message` envelope — * methods return the user directly on success or throw NextlyError. * * `invite` is present only when the account was created in invite mode; the * admin needs the link back to deliver it however they choose. */ type UserMutationResponse = MinimalUser$1 & { invite?: InviteArtifact; }; declare class UserMutationService extends BaseService { private readonly userConfig?; private readonly userExtSchemaService?; /** Last known merged field count — used to detect stale caches */ private lastMergedFieldCount; /** Cached runtime Drizzle table object for user_ext (regenerated when fields change) */ private userExtTable; /** Cached set of custom field names for quick lookup (regenerated when fields change) */ private customFieldNames; /** Set to true when a user_ext query fails (table missing), disabling ext operations */ private userExtDisabled; /** * Audit tables already confirmed to carry the erasure stamp, by table name. * * Only a confirmed stamp is cached. A table that is absent, or present on its * pre-erasure shape, is the state an operator fixes by upgrading, so those * are re-probed on each deletion: that costs one catalogue lookup on a rare * operation, where caching them would keep answering with a shape the * database has since left for the life of the process. */ private readonly erasableTables; /** Cached merged Zod schemas (lazy, rebuilt when merged fields are available) */ private createSchema; private updateSchema; private schemasBuiltWithMerged; /** * Creates a new UserMutationService instance. * * @param adapter - Database adapter for multi-database support * @param logger - Logger instance * @param userConfig - Optional user extension configuration * @param userExtSchemaService - Optional schema service for generating runtime user_ext table * @param emailService - Optional email service for sending welcome emails */ constructor(adapter: DrizzleAdapter, logger: Logger, userConfig?: UserConfig, userExtSchemaService?: UserExtSchemaService, emailService?: EmailService, fastDrainScheduler?: WebhookDrainOffer, retentionRunner?: WebhookRetentionOffer); /** * Get the effective custom fields for this service. * * Prefers merged fields from `UserExtSchemaService` (code + UI sources, * loaded via `loadMergedFields()` at startup) and falls back to * `userConfig.fields` (code-only from `defineConfig()`). */ private getEffectiveFields; /** * Check if custom user fields are configured (from either source). */ private hasCustomFields; /** * What this database can record about erasing a trail. * * Reports the SHAPE rather than a yes/no, because the two callers answer a * pre-erasure shape differently and only they can decide that. * * `false` — the table is absent, so there is no trail and no identifying data * to leave behind. `"unstamped"` — the table is there on its pre-erasure * shape, on a database whose upgrade did not reach it: the core reconcile * pushes only the static tables, and drizzle-kit's SQLite entrypoint takes no * table filter, so an ordinary `dc_*` content table reads as an orphan and * trips its rename resolver — after which the recovery pass can create * missing tables but never alters an existing one. The identifying columns * exist there; only the column recording WHEN an erasure happened does not. * * Neither answer fails the deletion. An account holder's right to have their * account removed does not depend on the state of a table they never saw, so * a shape that cannot be fully erased is reported and said out loud rather * than made a reason to refuse. What the caller does with * `"unstamped"` depends on the table: an un-erased `activity_log` row is * carried away by its cascading key, while an `audit_log` row has no key and * would keep its identifiers indefinitely, so that one is scrubbed without * the stamp rather than skipped. * * A probe that cannot run answers `"stamped"`, so an unreadable catalogue * leaves the erasure in place and the deletion fails loudly rather than * quietly skipping it. */ private supportsErasure; /** * Check if cached ext data is stale (merged fields changed since last cache). * If stale, clear caches so they are regenerated on next access. */ private ensureCachesFresh; /** * Get or lazily create the runtime Drizzle table object for user_ext. * Automatically invalidated when merged fields change. */ private getUserExtTable; /** * Get the set of custom field names for quick lookup. * Automatically invalidated when merged fields change. */ private getCustomFieldNames; /** * Get the Zod create schema, rebuilding with merged fields if needed. */ private getCreateSchema; /** * Get the Zod update schema, rebuilding with merged fields if needed. */ private getUpdateSchema; /** * Rebuild Zod validation schemas if merged fields are available * and haven't been incorporated yet. */ private ensureSchemasUpToDate; /** * Extract custom field values from input data. * Returns an object with only the keys that match configured custom field names. * Values are included even if null/undefined (to ensure user_ext row has all columns). */ private extractCustomFieldValues; private readonly emailService?; private readonly fastDrainScheduler?; private readonly retentionRunner?; private static readonly WRITE_PATH_PRUNE_BATCHES; /** * Create a new local user with password authentication. * * §13.8 + spec note: "User with this email already exists" is sensitive * (account enumeration) and now surfaces as a generic * NextlyError.duplicate(). Validation errors carry per-field paths but * never echo values; identifiers go to logContext. * * @param actor - Who initiated the write, recorded for event attribution. * Omitted for genuinely internal calls (seed, self-registration), which * record no actor. * @throws NextlyError(VALIDATION_ERROR) on input validation / invalid role ids. * @throws NextlyError(DUPLICATE) when the email is already registered. * @throws NextlyError on DB errors via fromDatabaseError. */ createLocalUser(userData: CreateLocalUserData, actor?: RequestActor): Promise; /** * Update an existing user's data. * * @throws NextlyError(VALIDATION_ERROR) on schema-validation failure or * when no actionable changes are provided. * @throws NextlyError(NOT_FOUND) when the user does not exist. * @throws NextlyError(DUPLICATE) on email conflicts. * @throws NextlyError on DB errors via fromDatabaseError. */ updateUser(userId: number | string, changes: UpdateUserData): Promise; /** * Delete a user and all related data (roles, accounts). * * §13.8 + spec note: user existence is sensitive (account enumeration); * the public message stays generic. The id flows only through logContext. * * @param actor - Who initiated the delete, recorded for event attribution. * @throws NextlyError(NOT_FOUND) when the user does not exist. * @throws NextlyError on DB errors via fromDatabaseError. */ deleteUser(userId: number | string, actor?: RequestActor): Promise; } /** * UserQueryService - Read operations for users * * Handles user listing, retrieval, and search operations with * filtering, pagination, and sorting capabilities. * * This service uses the database adapter pattern for multi-database support * (PostgreSQL, MySQL, SQLite). For complex queries like JOINs and relational * lookups, it uses direct Drizzle access via the compatibility layer until * the adapter is enhanced to support these features. * * When `UserConfig.fields` is configured, the service automatically LEFT JOINs * the `user_ext` table to include custom fields in user responses. Custom fields * appear as top-level properties in the response (transparent to consumers). * * @example * ```typescript * const queryService = new UserQueryService(adapter, logger); * * const users = await queryService.listUsers({ page: 1, limit: 10 }); * const user = await queryService.getUserById('user-id'); * ``` */ /** * Options for listing users with pagination, filtering, and sorting */ interface ListUsersOptions { page?: number; limit?: number; search?: string; emailVerified?: boolean; hasPassword?: boolean; createdAtFrom?: Date; createdAtTo?: Date; sortBy?: "createdAt" | "name" | "email" | (string & {}); sortOrder?: "asc" | "desc"; } /** * Response type for paginated user lists. * * Post-migration (PR 4): no `success`/`statusCode`/`message` envelope — * methods throw NextlyError on failure and return data directly on success. */ interface ListUsersResponse { data: MinimalUser$1[]; meta: { total: number; page: number; limit: number; totalPages: number; }; } /** * Response type for single user operations. * * Post-migration (PR 4): callers receive the user directly; missing users * surface via thrown NextlyError(NOT_FOUND) rather than a null `data`. */ type GetUserResponse = MinimalUser$1; declare class UserQueryService extends BaseService { private readonly userConfig?; private readonly userExtSchemaService?; private readonly _dialect; /** Last known merged field count — used to detect stale caches */ private lastMergedFieldCount; /** Cached runtime Drizzle table object for user_ext (regenerated when fields change) */ private userExtTable; /** Cached map of custom field names for quick lookup (regenerated when fields change) */ private customFieldNames; /** Set to true when a user_ext query fails, disabling ext joins until fields change */ private userExtDisabled; /** * Creates a new UserQueryService instance. * * @param adapter - Database adapter * @param logger - Logger instance * @param userConfig - Optional user extension configuration * @param userExtSchemaService - Optional schema service for generating runtime user_ext table */ constructor(adapter: DrizzleAdapter, logger: Logger, userConfig?: UserConfig, userExtSchemaService?: UserExtSchemaService); /** * Get the effective custom fields for this service. * * Prefers merged fields from `UserExtSchemaService` (code + UI sources, * loaded via `loadMergedFields()` at startup) and falls back to * `userConfig.fields` (code-only from `defineConfig()`). */ private getEffectiveFields; /** * Check if custom user fields are configured (from either source). */ private hasCustomFields; /** * Check if cached ext data is stale (merged fields changed since last cache). * If stale, clear caches so they are regenerated on next access. */ private ensureCachesFresh; /** * Get or lazily create the runtime Drizzle table object for user_ext. * Automatically invalidated when merged fields change. */ private getUserExtTable; /** * Get the set of custom field names for quick lookup. * Automatically invalidated when merged fields change. */ private getCustomFieldNames; /** * Build the select columns object for custom fields from user_ext. * Maps each custom field to its Drizzle column reference. */ private buildCustomFieldSelect; /** * Build search conditions for custom text-type fields. * Only text, textarea, and email fields are included in LIKE search. */ private buildCustomSearchConditions; /** * Resolve the order-by clause, supporting both built-in and custom field names. */ private resolveOrderByClause; /** * Extract custom field values from a query result row and return as flat object. */ private extractCustomFields; /** * List users with pagination, filtering, and sorting. * * @throws NextlyError on database errors (mapped via fromDatabaseError). */ listUsers(options?: ListUsersOptions): Promise; private _listUsersInternal; /** * Get a user by ID with their roles. * * §13.8: §"User abc not found" replaced with generic NOT_FOUND because * user-existence info is account-enumeration-sensitive — the id stays in * logContext only. * * @throws NextlyError(VALIDATION_ERROR) when the userId fails Zod schema. * @throws NextlyError(NOT_FOUND) when the user does not exist. * @throws NextlyError on database errors (mapped via fromDatabaseError). */ getUserById(userId: number | string): Promise; private _getUserByIdInternal; /** * Display-name projection for a set of user ids, for surfaces that show who * performed an action. * * Unknown ids are absent from the result rather than raising: unlike * `getUserById`, whose 404 exists to keep user existence unguessable, a * caller here already holds a record naming the id and only wants something * to render. A deleted user must degrade to an unattributed row. * * Columns are listed explicitly rather than selecting the row: the user * table carries a password hash and lockout counters that must never reach * a display surface. */ listUsersByIds(ids: string[]): Promise<{ id: string; name: string | null; }[]>; /** * Find a user by email address. * * Returns null when the email is not registered (callers explicitly need * to distinguish missing vs. found here, e.g. for the silent-success * password-reset flow). * * @throws NextlyError(VALIDATION_ERROR) when the email fails the Zod check. */ findByEmail(email: string): Promise; } /** * UserService - Unified service for user operations * * This service provides a clean API for user management operations following * the new service layer architecture with: * * - Exception-based error handling using NextlyError * - RequestContext for user/locale context * - PaginatedResult for list operations * * Internally delegates to UserQueryService, UserMutationService, and * UserAccountService for the actual implementation. * * @example * ```typescript * import { UserService, NextlyError } from 'nextly'; * * const service = new UserService(queryService, mutationService, accountService); * * // Create a user * const user = await service.create({ * email: 'user@example.com', * name: 'John Doe', * password: 'securePassword123', * }, context); * * // Authenticate * const authenticatedUser = await service.authenticate('user@example.com', 'password'); * * // Error handling * try { * const user = await service.findById('nonexistent', context); * } catch (error) { * if (NextlyError.isNotFound(error)) { * console.log(error.code); // 'NOT_FOUND' * console.log(error.statusCode); // 404 * } * } * ``` */ /** * User returned from operations (password hash never included) */ interface User { id: string; email: string; name: string | null; image?: string | null; emailVerified: Date | null; isActive?: boolean; roles?: string[] | null; createdAt?: Date; updatedAt?: Date; /** Custom fields from user_ext table — present when user extension fields are configured */ [key: string]: unknown; } /** * Input for creating a user */ interface CreateUserInput { email: string; name: string; password?: string; image?: string | null; roles?: string[]; isActive?: boolean; /** Custom field values from user_ext */ [key: string]: unknown; } /** * Input for updating a user */ interface UpdateUserInput { email?: string; name?: string; image?: string; emailVerified?: Date | null; isActive?: boolean; /** Custom field values from user_ext */ [key: string]: unknown; } /** * Options for listing users */ interface ListUsersQueryOptions { pagination?: { limit?: number; offset?: number; page?: number; }; search?: string; emailVerified?: boolean; hasPassword?: boolean; sortBy?: "createdAt" | "name" | "email"; sortOrder?: "asc" | "desc"; } /** * Password hasher interface for authentication */ interface PasswordHasher { hash(password: string): Promise; verify(password: string, hash: string): Promise; } /** * UserService - Unified service for user management * * Provides user CRUD operations, authentication, and password management with: * * - Exception-based error handling (throws NextlyError) * - Type-safe RequestContext * - PaginatedResult for list operations * - Logging support */ declare class UserService { private readonly queryService; private readonly mutationService; private readonly accountService; private readonly passwordHasher?; private readonly logger; constructor(queryService: UserQueryService, mutationService: UserMutationService, accountService: UserAccountService, passwordHasher?: PasswordHasher | undefined, logger?: Logger); /** * Create a new user * * @param input - User creation data * @param context - Request context with user info * @returns Created user (without password hash) * @throws NextlyError if creation fails (e.g., duplicate email) * * @example * ```typescript * const user = await service.create({ * email: 'user@example.com', * name: 'John Doe', * password: 'securePassword123', * }, context); * ``` */ create(input: CreateUserInput, context: RequestContext$2): Promise; /** * Find a user by ID. * * @param userId - User ID * @param context - Request context * @returns User data * @throws NextlyError(NOT_FOUND) if user doesn't exist */ findById(userId: string, _context: RequestContext$2): Promise; /** * Display names for a set of user ids, for surfaces that attribute an action * to a person. * * Takes no RequestContext because it exposes nothing beyond a name the * caller is already entitled to see alongside the record naming the id, and * unknown ids are simply omitted rather than raising. * * @param ids - User IDs to resolve * @returns One entry per id that still exists */ listUsersByIds(ids: string[]): Promise<{ id: string; name: string | null; }[]>; /** * Find a user by email address * * @param email - Email address * @param context - Request context * @returns User data or null if not found */ findByEmail(email: string, _context: RequestContext$2): Promise; /** * List users with pagination and filtering * * @param options - Query options (pagination, search, filters) * @param context - Request context * @returns Paginated list of users */ listUsers(options: ListUsersQueryOptions | undefined, _context: RequestContext$2): Promise>; /** * Update a user * * @param userId - User ID to update * @param input - Update data * @param context - Request context * @returns Updated user * @throws NextlyError if update fails */ update(userId: string, input: UpdateUserInput, _context: RequestContext$2): Promise; /** * Delete a user * * @param userId - User ID to delete * @param context - Request context * @throws NextlyError if deletion fails */ delete(userId: string, context: RequestContext$2): Promise; /** * Authenticate a user with email and password * * Verifies credentials only - does NOT create a session. * Use the returned user to create a session via your auth system. * * §13.8: every failure path uses the same generic * `NextlyError.invalidCredentials()` so an attacker cannot distinguish * "no such user" from "wrong password" — the email goes only to logContext. * * @param email - User email * @param password - User password * @returns Authenticated user (without password hash) * @throws NextlyError(AUTH_INVALID_CREDENTIALS) if authentication fails. * @throws NextlyError(INTERNAL_ERROR) if password hasher is not configured. * * @example * ```typescript * try { * const user = await service.authenticate('user@example.com', 'password'); * // Create session with your auth system * await createSession(user.id); * } catch (error) { * if (NextlyError.isCode(error, 'AUTH_INVALID_CREDENTIALS')) { * return { error: 'Invalid email or password' }; * } * } * ``` */ authenticate(email: string, password: string): Promise; /** * Change a user's password * * @param userId - User ID * @param currentPassword - Current password for verification * @param newPassword - New password to set * @throws NextlyError if password change fails */ changePassword(userId: string, currentPassword: string, newPassword: string): Promise; /** * Check if a user has a password set * * @param userId - User ID * @returns True if user has a password */ hasPassword(userId: string): Promise; /** * Update the current user's profile (name and image only) * * Use this for self-service profile updates where users can only * change their own name and image. * * @param userId - User ID * @param changes - Profile changes (name, image) * @param context - Request context * @returns Updated user */ updateProfile(userId: string, changes: { name?: string; image?: string; }, _context: RequestContext$2): Promise; /** * Get password hash by email (for authentication) */ private getPasswordHashByEmail; /** * Map legacy user data to User type * Converts id to string since legacy services may return number | string */ private mapToUser; } /** * Service Registration for DI Container * * Provides the async entrypoint `registerServices()` that bootstraps the * database adapter, media storage, and every Nextly domain service. The * individual domain registrations live in `./registrations/` — this file * is the orchestrator that stitches them together. * * **IMPORTANT:** `registerServices()` is async and must be awaited. * The database adapter is created and connected during registration for * fail-fast error handling and predictable initialization. * * @example * ```typescript * import { registerServices, getService } from 'nextly'; * * await registerServices({ * imageProcessor: getImageProcessor(), * logger: customLogger, // optional * }); * * const userService = getService('userService'); * const user = await userService.findById(userId, context); * ``` */ /** * Configuration for service registration. * * **Database Configuration:** if `adapter` is provided, it is used * directly. Otherwise, one is created from environment variables using * `DB_DIALECT` and `DATABASE_URL`. */ interface NextlyServiceConfig { /** * Database adapter for multi-database support. * If not provided, created automatically from environment variables. */ adapter?: DrizzleAdapter; /** Storage plugins for cloud storage providers (S3, Vercel Blob, etc.). */ storagePlugins?: StoragePlugin[]; /** Image processor for media operations. */ imageProcessor: ImageProcessor; /** Optional logger instance. Defaults to `consoleLogger`. */ logger?: Logger; /** Optional hook registry. When absent, hooks are disabled. */ hookRegistry?: HookRegistry; /** Optional password hasher for user authentication. */ passwordHasher?: { hash(password: string): Promise; verify(password: string, hash: string): Promise; }; /** Optional base path for collection file operations. */ basePath?: string; /** Optional directory for dynamic collection schemas. */ schemasDir?: string; /** * Whether this boot will run migrations, so registration can open the * boot-migrations gate before it publishes the container. * * Carried on the SERVICE config rather than read from `db` — which this shape * flattens away — because `buildServiceConfig` is the one builder both boot * paths use, so threading it there reaches both without either remembering. */ runMigrationsOnBoot?: boolean; /** Optional directory for dynamic collection migrations. */ migrationsDir?: string; /** Plugins to initialize with Nextly. */ plugins?: PluginDefinition[]; /** * @experimental Fail fast (throw) when a plugin `extend`/relation targets an * entity that is NEITHER a code/plugin entity NOR a Builder collection/single/ * component. Default `false`: such a target is warned-and-skipped so a typo or * a removed Builder entity can't take the whole app down (P8). Also enabled by * `NEXTLY_STRICT_PLUGIN_TARGETS=1` (recommended for CI/production). */ strictPluginTargets?: boolean; /** @experimental App-declared custom permissions, seeded like plugin permissions (D36). */ permissions?: PluginPermission[]; /** @experimental App-declared role bundles, seeded like plugin roles (D67). */ roles?: PluginRole[]; /** Collection configurations. */ collections?: CollectionConfig[]; /** Single (global document) configurations. */ singles?: SingleConfig[]; /** Field Group (reusable field structure) configurations. */ fieldGroups?: FieldGroupConfig[]; /** User model extension configuration. */ users?: UserConfig; /** Email system configuration. */ email?: EmailConfig; /** API key authentication configuration with defaults applied. */ apiKeys?: SanitizedApiKeysConfig; /** Security configuration (headers, CORS, uploads, sanitization). */ security?: SecurityConfig; /** * Admin panel configuration (branding, plugin overrides, devAutoLogin). * Carried through from `nextly.config.ts` so handlers that read from the * DI's "config" service can see admin-level toggles. Without this the * admin object gets dropped during buildServiceConfig. */ admin?: AdminConfig; /** * Authentication configuration (revealRegistrationConflict and friends). * Same rationale as admin: carried through so handlers can read it. */ auth?: AuthConfig; /** * Content-localization configuration (i18n), normalized. Carried through so the * collection read path can resolve a requested locale to its fallback chain when * populating localized fields from the companion `_locales` table. */ localization?: SanitizedLocalizationConfig; /** * Resolved webhook retention policy, carried through so services can offer a * retention pass without re-deriving it. Null when the user switched * retention off; absent when this container was built without app config. */ webhookRetention?: ResolvedWebhookRetentionConfig | null; /** * Resolved audit-trail retention windows. * * Always a policy once the sanitizer has run, since the windows have * defaults; `undefined` means it was never carried through initialization, in * which case no audit pass is registered and neither trail is pruned. */ auditRetention?: ResolvedAuditRetentionConfig; /** * Resolved delivery-log retention. * * Read by the email registration to decide whether to offer a sweep from the * send path. `undefined` means it was never carried through initialization, * in which case nothing prunes `email_deliveries` and the table grows with * every send — which is the state this exists to end, so absence is a real * outcome rather than a neutral default. */ emailRetention?: ResolvedEmailRetentionConfig; /** * Whether the audit seam forces outbox recording regardless of endpoints. * Carried from the sanitized config; absent when built without app config. */ webhookAuditEnabled?: boolean; } /** * Type-safe service map returned by `getService()`. */ interface ServiceMap { adapter: DrizzleAdapter; logger: Logger; config: NextlyServiceConfig; mediaStorage: MediaStorage; collectionService: CollectionService; collectionRegistryService: CollectionRegistryService; userService: UserService; mediaService: MediaService; singleRegistryService: SingleRegistryService; singleEntryService: SingleEntryService; /** Owns a Single's table change together with the registry write that records it. */ singleMetadataService: SingleMetadataService; fieldGroupMetadataService: FieldGroupMetadataService; fieldGroupRegistryService: FieldGroupRegistryService; fieldGroupSchemaService: FieldGroupSchemaService; fieldGroupDataService: FieldGroupDataService; relationshipService: CollectionRelationshipService; userExtSchemaService: UserExtSchemaService; emailProviderService: EmailProviderService; emailTemplateService: EmailTemplateService; emailDeliveryService: EmailDeliveryService; emailService: EmailService; userFieldDefinitionService: UserFieldDefinitionService; permissionSeedService: PermissionSeedService; rbacAccessControlService: RBACAccessControlService; apiKeyService: ApiKeyService; /** Webhook endpoint management, resolved by the webhooks REST handlers. */ webhookEndpointService: WebhookEndpointService; /** Read-only webhook delivery log, resolved by the webhooks REST handlers. */ webhookDeliveryQueryService: WebhookDeliveryQueryService; authService: AuthService; generalSettingsService: GeneralSettingsService; activityLogService: ActivityLogService; dashboardService: DashboardService; metaService: MetaService; versionsService: VersionsService; collectionsHandler: CollectionsHandler; } /** * Register all Nextly services in the DI container. * * This function should be called once during application initialization. * Services are registered as singletons and lazily initialized on first access. * * @param config - Service configuration with required dependencies * @throws Error if called multiple times (use `clearServices()` first) * @throws Error if database environment configuration is invalid * @throws Error if database connection fails */ declare function registerServices(config: NextlyServiceConfig): Promise; /** * Get a service from the container with type safety. * Services must be registered first via `registerServices()`. */ declare function getService(name: T): ServiceMap[T]; /** * Check if services have been registered. */ declare function isServicesRegistered(): boolean; declare function shutdownServices(): Promise; /** * Clear all registered services. Primarily for testing or re-initialization * with different configuration. For production shutdown, prefer * `shutdownServices()` so resources are properly released. */ declare function clearServices(): void; /** * Plugin Event Bus (D8 / D51) * * A first-class, in-process event bus that is **typed, async, observe-only, * post-commit, and error-isolated**. Events are the *reaction* path — use a * hook (synchronous, in-transaction, can modify/abort) for must-happen work, * and an event to react/notify. Delivery is **best-effort**: a failing handler * is logged and isolated, never surfaced to the emitter (D51). A durable * backend may be added later (additive, like webhooks). * * Mirrors the {@link HookRegistry} `globalThis` singleton pattern so the bus * survives ESM module duplication under Next.js/Turbopack — without this, * subscriptions registered during `init()` would be lost on re-evaluation. * * @module events/event-bus */ /** Minimal logger shape used for isolated-failure diagnostics. */ interface EventLogger { warn?: (message: string, meta?: Record) => void; error?: (message: string, meta?: Record) => void; } /** * Event name. * * When generated types exist (run `nextly generate:types`), this narrows to the * union of known event names (per-collection `collection.*` events, the core * document/auth/media families, `plugin.initialized`, and each plugin's declared * `contributes.events`, D47). Without generated types it falls back to `string` * (same convention as `CollectionSlug`), so arbitrary names still emit/subscribe. */ type EventName = GeneratedTypes extends { events: infer E; } ? keyof E & string : string; /** The envelope every handler receives. */ interface EventEnvelope

{ name: EventName; payload: P; } /** Observe-only event handler. Return value is ignored (cannot modify/abort). */ type EventHandler

= (event: EventEnvelope

) => void | Promise; declare class EventBus { private handlers; private declaredEvents; private inFlight; private logger; /** * Provide a logger for isolated-failure diagnostics. Falls back to `console` * when unset. The runtime wires the resolved Nextly logger at boot. */ setLogger(logger: EventLogger): void; /** * Record custom event names declared via `contributes.events` (D9) so they * are introspectable and emit without a warning. */ registerDeclaredEvents(names: EventName[]): void; /** All declared custom event names (introspection). */ getDeclaredEvents(): EventName[]; /** Subscribe to an event. */ on

(name: EventName, handler: EventHandler

): void; /** Unsubscribe a previously-registered handler. */ off

(name: EventName, handler: EventHandler

): void; /** * Emit an event. Fire-and-forget, observe-only, best-effort: handlers run in * registration order, each isolated so one failure never blocks the others * or the emitter (D51). Returns immediately — use {@link settle} in tests to * await in-flight async handlers. */ emit

(name: EventName, payload: P): void; /** * Await all in-flight async handlers. **Testing aid** — production emit is * fire-and-forget. Drains repeatedly so handler chains that emit again settle * too. */ settle(): Promise; /** Remove all handlers and declared events. For testing/teardown. */ clear(): void; private isKnownName; private logError; } /** Get the global event bus singleton. Always use this for shared access. */ declare function getEventBus(): EventBus; /** Reset the global event bus (testing only). */ declare function resetEventBus(): void; /** * Filter Registry (D63) * * A typed, async, error-isolated filter and action registry that follows the * WordPress-style filter/action model. Filters transform a value (threading it * through each registered handler); actions fire for side effects only. * * Both filters and actions are **error-isolated**: a throwing handler is logged * and skipped; the value/execution continues with the remaining handlers. * * Mirrors the {@link EventBus} and {@link HookRegistry} `globalThis` singleton * pattern so the registry survives ESM module duplication under Next.js/Turbopack. * * @module filters/filter-registry */ /** * @experimental The seam/registry key used by BOTH filters and actions (D63). * Pass this as the `name` argument to `addFilter`, `addAction`, `applyFilters`, * `runActions`, and `removeFilter`/`removeAction`. */ type FilterName = string; /** @experimental A value-transforming handler registered via {@link FilterRegistry.addFilter} (D63). */ type Filter = (value: V, context: C) => V | Promise; /** @experimental A side-effect handler registered via {@link FilterRegistry.addAction} (D63). */ type Action

= (payload: P, context: C) => void | Promise; /** @experimental Minimal logger shape for filter/action error diagnostics (D63). */ interface FilterLogger { warn?(message: string, meta?: unknown): void; error?(message: string, meta?: unknown): void; } /** * @experimental Typed, async, error-isolated filter and action registry (D63). * * Filters thread a value through each registered handler in registration order; * actions fire for side effects only. A throwing handler is logged and skipped — * the value/execution continues with the remaining handlers. */ declare class FilterRegistry { private filters; private actions; private logger?; setLogger(logger: FilterLogger): void; addFilter(name: FilterName, fn: Filter): void; removeFilter(name: FilterName, fn: Filter): void; applyFilters(name: FilterName, value: V, context: C): Promise; addAction

(name: FilterName, fn: Action): void; removeAction

(name: FilterName, fn: Action): void; runActions

(name: FilterName, payload: P, context: C): Promise; clear(): void; hasFilters(name: FilterName): boolean; hasActions(name: FilterName): boolean; private logError; } /** Get the global filter registry singleton. Always use this for shared access. */ declare function getFilterRegistry(): FilterRegistry; /** Reset the global filter registry (testing only). */ declare function resetFilterRegistry(): void; /** * Admin Placement Constants * * Typed constants for valid admin sidebar placement sections. * Plugin developers use these to declare where their plugin * renders in the admin sidebar with full TypeScript autocomplete. * * @module plugins/admin-placement * @since 1.0.0 * * @example * ```typescript * import { definePlugin, AdminPlacement } from "nextly"; * * export const analyticsPlugin = definePlugin({ * name: "Analytics Dashboard", * admin: { * placement: AdminPlacement.USERS, * order: 60, * description: "User analytics and insights", * }, * }); * ``` */ /** * Valid sidebar placement sections for plugins. * * Use these constants when specifying `admin.placement` in a plugin definition. * Each value maps to a built-in sidebar section in the admin UI. * * @example * ```typescript * // Place plugin items alongside collections * admin: { placement: AdminPlacement.COLLECTIONS } * * // Place plugin items in the Users inner sidebar * admin: { placement: AdminPlacement.USERS } * ``` */ declare const AdminPlacement: { /** Plugin items appear in the Collections sidebar section */ readonly COLLECTIONS: "collections"; /** Plugin items appear in the Singles sidebar section */ readonly SINGLES: "singles"; /** Plugin items appear in the Users inner sidebar (alongside Users, User Fields, Roles) */ readonly USERS: "users"; /** Plugin items appear in the Settings inner sidebar (alongside General, API Keys, etc.) */ readonly SETTINGS: "settings"; /** Plugin items appear in the dedicated Plugins sidebar section (default) */ readonly PLUGINS: "plugins"; /** Plugin gets its own top-level icon in the sidebar (requires appearance.icon) */ readonly STANDALONE: "standalone"; }; /** * Type representing valid admin sidebar placement values. * * Derived from the `AdminPlacement` constants object. * Accepts: `"collections"` | `"singles"` | `"users"` | `"settings"` | `"plugins"` | `"standalone"` */ type AdminPlacement = (typeof AdminPlacement)[keyof typeof AdminPlacement]; /** * The controlled vocabulary a plugin declares in `category`. * * Its own module, with no imports, so `nextly/config` can export it to the * admin without pulling in the plugin runtime. `plugin-context.ts` reaches the * event bus, the filter registry and the DI container; a client bundle that * only wants the list of categories must not pay for any of that. * * @module nextly/plugins/plugin-categories */ /** * Deliberately short: a category is only useful when several plugins can share * it, so new values are added here rather than typed ad hoc. * * A runtime array rather than a bare union, because consumers need to iterate * it to build a filter and to narrow a third-party plugin's free-form * `category`. A union alone cannot be enumerated at runtime, so a consumer * that needs the values has no option but to write them out again, and that * copy starts accepting a category plugins cannot declare the moment either * side changes. */ declare const PLUGIN_CATEGORIES: readonly ["content", "forms", "seo", "media", "commerce", "integration", "dev-tools", "other"]; type PluginCategory = (typeof PLUGIN_CATEGORIES)[number]; /** * Narrow a free-form `category` to the vocabulary. * * A third-party plugin can declare anything, so a surface rendering plugin * metadata must keep tolerating an unknown value rather than throwing. This * exists so first-party data can be checked instead of assumed. */ declare function isPluginCategory(value: string | undefined): value is PluginCategory; /** * Resolved self-identity for a plugin (`ctx.self`, D54). * * Plugins reference their own entities through `ctx.self.collections[...]` * rather than hardcoding slugs, so that the framework-owned remap (D54, * shipped) can rename a contributed entity at registration without * breaking the plugin's code. * * In P1 this resolution is **identity** — every declared owned slug maps to * itself — because the `.rename()` remap API lands. Introducing the * shape now is the forward-compat affordance: third-party plugins that read * `ctx.self.*` today keep working unchanged once remap arrives. * * @module plugins/self */ interface PluginSelf { /** The plugin's own name. */ name: string; /** Owned collection slugs → resolved slug (identity). */ collections: Record; /** Owned single slugs → resolved slug (identity). */ singles: Record; } /** * @public Elevation options for the managed `ctx.services` path. * Default: `system` when no `user` is supplied (no-user → system). Validation/ * hooks/events ALWAYS run, even under `system` — only the access check is bypassed. * * Under `as:'user'`, RBAC is enforced by `user.id` (DB lookup). Code-defined * `access` rules that read `ctx.user.role` see it empty — pass `system`, or rely on * DB RBAC, for now (documented v1 limitation). */ interface ServiceOpts { as?: "user" | "system"; user?: AuthUser; } /** * The collection-facade access methods, mapped to the position of their trailing * `RequestContext` argument. The wrapper translates a `ServiceOpts` passed at this * position into a `RequestContext`. */ type AccessMethod = "createEntry" | "listEntries" | "findEntryById" | "updateEntry" | "deleteEntry" | "count" | "createMany"; /** * The write methods, and the verb each reports. * * A write is where a post-commit hook can fail after the row is already * durable, so these are the methods whose result has something to say beyond * the row itself. The reads are left exactly as they are: nothing runs after * them that could fail without failing the read. */ declare const WRITE_VERB: { readonly createEntry: "created"; readonly updateEntry: "updated"; readonly deleteEntry: "deleted"; }; type WriteMethod = keyof typeof WRITE_VERB; /** Replace a method's trailing `RequestContext` arg with an optional `ServiceOpts`. */ type ReplaceTrailingContext = F extends (...args: [...infer Head, RequestContext$2]) => infer R ? (...args: [...Head, ServiceOpts?]) => R : F; /** * The plugin-facing return type for a write. * * `deleteEntry` resolves to `void` on the facade, so the deleted row is * reported as the minimal `{ id }` the Direct API already uses for it -- a * caller that wants to log or re-key what it removed has the id, and there is * no row left to return. */ type WriteResult = K extends "deleteEntry" ? MutationResult<{ id: string; }> : MutationResult; /** Replace a write's trailing context AND widen its result to the envelope. */ type PluginWriteMethod = ReplaceTrailingContext extends (...args: infer A) => unknown ? (...args: A) => Promise> : never; /** * @public Plugin-facing collection service. * * Access methods take `ServiceOpts` in place of a `RequestContext`, and the * writes resolve to the same `{ message, item, warnings? }` envelope the Direct * API and the wire API return. Returning the bare row left a plugin unable to * see a post-commit hook failure that every other caller of the same write is * told about. */ type PluginCollectionService = Omit & { [K in Exclude]: ReplaceTrailingContext; } & { [K in WriteMethod]: PluginWriteMethod; }; /** * Plugin Context System * * Provides a type-safe context for plugins to access Nextly services. * Plugins receive this context during initialization, enabling them * to interact with core services and register hooks. * * @module plugins/plugin-context * @since 1.0.0 */ /** * Simplified hook registry interface for plugins. * * Provides only the methods plugins should use (register/unregister hooks). * Internal methods like `execute()` and `clear()` are not exposed. * * @example * ```typescript * export const myPlugin = definePlugin({ * name: 'my-plugin', * * async init(nextly) { * // Register a beforeCreate hook * nextly.hooks.on('beforeCreate', 'posts', async (context) => { * context.data.slug = slugify(context.data.title); * return context.data; * }); * * // Register a global hook (all collections) * nextly.hooks.on('afterCreate', '*', async (context) => { * nextly.logger.info(`Created ${context.collection}:${context.data?.id}`); * }); * } * }); * ``` */ interface PluginHookRegistry { /** * Register a hook for a specific collection and hook type. * * @param hookType - Type of hook (beforeCreate, afterCreate, etc.) * @param collection - Collection name or '*' for global hooks * @param handler - Hook function to execute * * @example * ```typescript * // Collection-specific hook * nextly.hooks.on('beforeCreate', 'users', async (context) => { * context.data.password = await bcrypt.hash(context.data.password, 10); * return context.data; * }); * * // Global hook (runs for all collections) * nextly.hooks.on('afterDelete', '*', async (context) => { * console.log(`Deleted from ${context.collection}`); * }); * ``` * * @typeParam T - The document shape. Pass it to get a typed `context.data` * instead of casting — prefer this over `as unknown as`: * ```typescript * interface Post { id: string; title: string; status: string } * nextly.hooks.on('beforeCreate', 'posts', (context) => { * // context.data is typed Post — no cast needed * if (context.data?.status === 'published') { ... } * return context.data; * }); * ``` */ on(hookType: HookContextPhase, collection: string, handler: HookHandler): void; /** * Unregister a previously registered hook. * * @param hookType - Type of hook * @param collection - Collection name or '*' * @param handler - The exact handler function to remove * * @example * ```typescript * const myHook = async (context) => { ... }; * * // Register * nextly.hooks.on('beforeCreate', 'posts', myHook); * * // Later, unregister * nextly.hooks.off('beforeCreate', 'posts', myHook); * ``` */ off(hookType: HookContextPhase, collection: string, handler: HookHandler): void; /** * Register a `beforeOperation` hook. * * Separate from {@link on} because the handler is shaped differently: it * receives the operation's `args` -- the data, id or where clause about to be * used -- rather than a document, and returning a modified set replaces them. * * @param collection - Collection name or '*' for all collections * @param handler - Hook function to execute * * @example * ```typescript * nextly.hooks.onBeforeOperation('posts', (context) => { * if (context.operation === 'read') { * return { ...context.args, where: { ...context.args.where, archived: false } }; * } * }); * ``` */ onBeforeOperation(collection: string, handler: BeforeOperationHandler): void; /** * Unregister a `beforeOperation` hook, the counterpart to * {@link onBeforeOperation}. * * @param collection - Collection name or '*' * @param handler - The exact handler function to remove */ offBeforeOperation(collection: string, handler: BeforeOperationHandler): void; } /** * @experimental Typed filter registry exposed to plugins. * Register transforms on named seams, or define + apply your own seams. */ interface PluginFilterRegistry { add(name: string, fn: Filter): void; remove(name: string, fn: Filter): void; apply(name: string, value: V, context: C): Promise; } /** * @experimental Typed action registry exposed to plugins. * Register ordered, error-isolated side-effects on named seams, or run your own. */ interface PluginActionRegistry { add

(name: string, fn: Action): void; remove

(name: string, fn: Action): void; run

(name: string, payload: P, context: C): Promise; } /** * PluginContext - Type-safe context for plugin service access. * * Plugins receive this context during initialization, providing * access to all Nextly services and infrastructure. * * The context provides: * - `services`: Core business logic services (collections, users, media, email) * - `db` / `logger`: Raw database escape hatch + diagnostics logger * - `events`: Post-commit, observe-only event bus * - `self` / `nextlyVersion`: Resolved own-entity names + core version * - `config`: Read-only configuration * - `hooks`: Hook registration for lifecycle events * * @example * ```typescript * import { definePlugin, NextlyError } from '@nextlyhq/plugin-sdk'; * * export const myPlugin = definePlugin({ * name: 'my-plugin', * version: '1.0.0', * * async init(nextly) { * // Access services with full TypeScript autocomplete * const { collections, users, media } = nextly.services; * * // Register hooks * nextly.hooks.on('beforeCreate', 'posts', async (context) => { * // Validate that author exists * const author = await users.findById(context.data.authorId, {}); * if (!author) { * // A NextlyError, not a plain one: a plain Error reads as a crash and * // its message is replaced before the caller sees it. * throw NextlyError.validation({ * errors: [ * { path: 'authorId', code: 'NOT_FOUND', message: 'Author not found.' }, * ], * }); * } * return context.data; * }); * * // Use infrastructure * nextly.logger.info('MyPlugin initialized'); * } * }); * ``` */ interface PluginContext { /** * @public Core services with full TypeScript autocomplete — the managed, * secure-by-default data path. Prefer this over `ctx.db`. * * Provides access to the unified service layer for: * - Collections: CRUD operations on dynamic collections * - Users: User management and authentication * - Media: File upload and management */ services: { /** * Collection service for CRUD on dynamic collections. Access methods accept * `ServiceOpts` (`as`/`user`) — secure-by-default; no-user runs as system. */ collections: PluginCollectionService; /** User service for user management */ users: UserService; /** Media service for file operations */ media: MediaService; /** Email service for sending emails via templates and providers */ email: EmailService; /** * @experimental Read-only content version history (list/get). Restore and * diff arrive in later stages. */ versions: VersionsService; /** * @experimental Services contributed by plugins, keyed by plugin name * then service name. Lazily resolved (instantiated on first access). Runtime * type is `unknown` — cast to your service's type, or export it from the * providing plugin. */ plugins: Record>; }; /** * @experimental Raw Drizzle database instance — the full escape hatch. * Unmanaged: bypasses validation/hooks/RBAC/events. Prefer `services`. */ db: DatabaseInstance; /** @experimental Logger for plugin diagnostics. */ logger: Logger; /** * @public Post-commit, observe-only, best-effort event bus. * Use a hook to modify/abort; use an event to react/notify. */ events: EventBus; /** * @experimental Running Nextly core version, for feature-detection. * e.g. "0.0.2-alpha.21". */ nextlyVersion: string; /** * @experimental Resolved names for this plugin's own entities. Read * `ctx.self.collections[...]` instead of hardcoding slugs so the P2 remap can * rename them transparently. Identity-resolved. */ self: PluginSelf; /** * Read-only configuration. * * Contains the Nextly service configuration. * Configuration is frozen to prevent accidental modification. */ config: Readonly; /** * @experimental Hook registration for lifecycle events. Allows plugins to * register hooks that run before/after database operations on collections. * No first-party plugin registers via `ctx.hooks` yet (see STABILITY.md). */ hooks: PluginHookRegistry; /** @experimental Typed filter registry. Transform values at named seams. */ filters: PluginFilterRegistry; /** @experimental Typed action registry. Ordered side-effects at named seams. */ actions: PluginActionRegistry; } /** * Sidebar appearance customization for plugins. * * Allows plugin authors to customize how their plugin appears * in the admin sidebar. All fields are optional — unset fields * use sensible defaults (Package icon, plugin name as label). * * @example * ```typescript * admin: { * appearance: { * icon: "BarChart", // Lucide icon name * label: "Analytics", // Custom sidebar label * badge: "Beta", // Badge text * badgeVariant: "secondary", * }, * } * ``` */ interface PluginAdminAppearance { /** Lucide icon name for the plugin's sidebar entry */ icon?: string; /** * URL of an image the plugin ships, for a plugin that wants its own branding * rather than a built-in glyph. Takes precedence over `icon` where both are * declared, and the admin scales it rather than cropping, so a rectangular * logo keeps its proportions. * * `icon` remains the common case: a lucide name is theme-aware by * construction, while an image has to work on both the light and the dark * surface on its own. */ iconAsset?: string; /** Custom label override (defaults to plugin name) */ label?: string; /** Badge text shown next to the plugin name (e.g., "Beta", "New") */ badge?: string; /** Badge variant for styling */ badgeVariant?: "default" | "secondary" | "destructive" | "outline"; } /** * Plugin admin configuration for sidebar placement and appearance. * * Allows plugins to declare their sidebar placement, sort order, * appearance customization, and description for the plugin settings page. * * @example * ```typescript * import { definePlugin, AdminPlacement } from 'nextly'; * * export const analyticsPlugin = definePlugin({ * name: 'Analytics Dashboard', * admin: { * placement: AdminPlacement.USERS, * order: 60, * description: 'User analytics and insights', * appearance: { * icon: 'BarChart', * label: 'Analytics', * badge: 'Beta', * badgeVariant: 'secondary', * }, * }, * }); * ``` */ interface PluginAdminConfig { /** * Immutable sidebar placement for this plugin's items. * * Use `AdminPlacement` constants for TypeScript autocomplete: * - `AdminPlacement.COLLECTIONS` (Collections section) * - `AdminPlacement.SINGLES` (Singles section) * - `AdminPlacement.USERS` (Users inner sidebar) * - `AdminPlacement.SETTINGS` (Settings inner sidebar) * - `AdminPlacement.PLUGINS` (Plugins section, default) * * If not set, falls back to `"plugins"`. */ placement?: AdminPlacement; /** Sort order when placed in a group (lower = higher position, default: 100) */ order?: number; /** * Position anchor for standalone plugins. * Specifies which built-in sidebar section this plugin's icon appears after. * * Valid values: `"dashboard"` | `"collections"` | `"singles"` | `"media"` | `"plugins"` | `"users"` * * Only applies when `placement` is `AdminPlacement.STANDALONE`. * If multiple standalone plugins share the same `after`, they are sorted by `order`. * Defaults to `"plugins"` (after the Plugins icon). * * @example * ```ts * admin: { * placement: AdminPlacement.STANDALONE, * after: "collections", // icon appears right after Collections * order: 10, * } * ``` */ after?: "dashboard" | "collections" | "singles" | "media" | "plugins" | "users" | "settings"; /** Plugin description shown on the plugin settings page */ description?: string; /** Sidebar appearance customization (icon, label, badge) */ appearance?: PluginAdminAppearance; } /** * Plugin definition interface. * * Defines the structure of a Nextly plugin. Plugins can: * - Initialize with access to PluginContext * - Transform configuration before services are registered * * @example * ```typescript * import { definePlugin } from 'nextly'; * * export const auditLogPlugin = definePlugin({ * name: 'audit-log', * version: '1.0.0', * * async init(nextly) { * // Log all create/update/delete operations * const logOperation = async (context) => { * nextly.logger.info('Audit', { * collection: context.collection, * operation: context.operation, * user: context.user?.id, * timestamp: new Date().toISOString(), * }); * }; * * nextly.hooks.on('afterCreate', '*', logOperation); * nextly.hooks.on('afterUpdate', '*', logOperation); * nextly.hooks.on('afterDelete', '*', logOperation); * } * }); * ``` */ interface PluginDefinition { /** * Unique plugin name. * Used for identification and error messages. */ name: string; /** * Plugin semver version. * Required so that other plugins' `dependsOn` ranges can be checked. */ version: string; /** * @public Author shown in the admin plugins list (a person or an * organization). Convention: mirror the package.json `author` value. */ author?: string; /** * @public Homepage URL, linked from the admin plugin detail page. * Convention: mirror the package.json `homepage` value. */ homepage?: string; /** * @public Source repository URL, linked from the admin plugin detail page. * Convention: mirror the package.json `repository` URL. */ repository?: string; /** * @public Documentation URL, when the docs live somewhere other than the * homepage. Omit if `homepage` already points at the docs. */ docsUrl?: string; /** * @public SPDX license identifier (e.g. `"MIT"`), shown on the admin plugin * detail page. Convention: mirror the package.json `license` value. */ license?: string; /** * @public Category the admin plugins list groups and filters by. * A controlled vocabulary rather than free text so filtering stays useful. */ category?: PluginCategory; /** * @public Free-form descriptive tags, shown on the admin plugin detail * page. Unlike `category` these are not used for filtering. */ tags?: string[]; /** * @public Core-compatibility range, boot-checked. May span majors, * e.g. `'^1 || ^2'`. Prereleases (alpha/beta) count as in-range. */ nextly: string; /** * @experimental Required plugin dependencies → version range. * Plugins are topologically sorted so dependencies initialize first. */ dependsOn?: Record; /** * @experimental Enhance-if-present dependencies → version range. * Absent optional deps are fine; present-but-incompatible fails fast. */ optionalDependsOn?: Record; /** * @experimental Default `true`. `false` skips behavior (init/hooks/events/ * routes/admin) but STILL applies declarative schema. Behavior-skip is * wired. */ enabled?: boolean; /** * @public Declarative contributions — introspectable without running * the plugin. Consumed incrementally by later phases. See {@link PluginContributions}. */ contributes?: PluginContributions; /** * Collections provided by this plugin. * * @deprecated Prefer `contributes.collections` (wired by the schema pipeline in * P2). Still read by the admin sidebar (routeHandler) — kept for backward * compatibility and merged today via the plugin's own `setup` transformer. */ collections?: CollectionConfig[]; /** * Admin configuration for sidebar placement and plugin metadata. * * Controls where the plugin's items appear in the sidebar (placement/order) * and its appearance + settings-page blurb. This is **complementary** to * `contributes.admin`: `admin` = placement & appearance; `contributes.admin` * = the declarative menu/pages/settings/views surface. Both are retained. */ admin?: PluginAdminConfig; /** * @public Escape-hatch config transformer; all `setup`s run before any * `init`. Don't mutate the config — spread and return a new object. * * @param config - Current configuration * @returns Modified configuration */ setup?: (config: NextlyServiceConfig) => NextlyServiceConfig; /** * @public Plugin initialization function. * * Called after all services are registered. * Receives PluginContext for service access and hook registration. * * @param context - PluginContext with services, db, logger, events, config, hooks */ init?: (context: PluginContext) => Promise | void; /** * @public Teardown on shutdown / HMR / test teardown. * Invocation is wired. */ destroy?: (context: PluginContext) => Promise | void; /** * @experimental Framework-owned entity remap. Rename this plugin's * contributed entity slugs at registration — declared slug → new slug — to * avoid collisions or match house naming. Returns a NEW definition; the * plugin keeps working because it references its own entities via `ctx.self`. * * @example * ```ts * defineConfig({ plugins: [formBuilder().plugin.rename({ forms: "contact-forms" })] }) * ``` */ rename?: (map: Record) => PluginDefinition; /** * @internal Accumulated declared-slug → new-slug map from `rename()`. * Consumed by the schema fold (renames merged slugs + the plugin's own * `relationTo`) and by `resolvePluginSelf` (builds `ctx.self`). Not for * plugin authors to set directly. */ renameMap?: Record; } /** * Define a plugin with type safety. * * This is a helper function that provides TypeScript autocomplete * when defining plugins. It simply returns the definition as-is. * * @param definition - Plugin definition * @returns The same definition (for type inference) * * @example * ```typescript * import { definePlugin } from 'nextly'; * * export const myPlugin = definePlugin({ * name: 'my-plugin', * version: '1.0.0', * * async init(nextly) { * // Full TypeScript autocomplete available * nextly.services.collections.listCollections(); * } * }); * ``` */ declare function definePlugin(definition: PluginDefinition): PluginDefinition; /** * Create a PluginContext from the DI container. * * This factory function creates a PluginContext by retrieving * services from the container. It should be called after * `registerServices()` has been invoked. * * The config is frozen to prevent accidental modification. * * @param getServiceFn - Function to get services from container * @param hookRegistry - Hook registry for plugin hook registration * @returns Fully initialized PluginContext * * @example * ```typescript * import { getService, getHookRegistry } from 'nextly'; * * // Create context for plugin initialization * const context = createPluginContext(getService, getHookRegistry()); * * // Initialize plugins * for (const plugin of plugins) { * await plugin.init?.(context); * } * ``` */ declare function createPluginContext(getServiceFn: (name: T) => T extends "collectionService" ? CollectionService : T extends "userService" ? UserService : T extends "mediaService" ? MediaService : T extends "emailService" ? EmailService : T extends "versionsService" ? VersionsService : T extends "db" ? DatabaseInstance : T extends "logger" ? Logger : T extends "config" ? NextlyServiceConfig : never, hookRegistry: { register: (hookType: HookContextPhase, collection: string, handler: HookHandler, owner?: HookOwner) => void; unregister: (hookType: HookContextPhase, collection: string, handler: HookHandler, owner?: HookOwner) => void; registerBeforeOperation: (collection: string, handler: BeforeOperationHandler, owner?: HookOwner) => void; unregisterBeforeOperation: (collection: string, handler: BeforeOperationHandler, owner?: HookOwner) => void; }, /** * The plugin this context is built for — used to resolve `ctx.self`. * Optional so the factory stays usable without a plugin (empty `self`). */ plugin?: PluginDefinition): PluginContext; /** * Auth extensibility contracts (D71/D57). * * The plugin-facing surface for extending authentication: pluggable strategies * ("who is this user"), an auth-flow hook pipeline (modify / abort / challenge), * and a first-class multi-step challenge protocol. Re-exported `@experimental` * from `@nextlyhq/plugin-sdk` (see that package's STABILITY.md). * * @experimental */ /** * @experimental What an auth strategy receives. `body` is the parsed JSON * request body; `strategyName` is the strategy currently being attempted. */ interface AuthInput { request: Request; body: Record; strategyName: string; } /** * @experimental A pending second factor / step. `id` is the challenge * definition id; `userId` is the candidate user this challenge gates (never * surfaced to the client raw — only via the signed pending-auth token). */ interface Challenge { id: string; userId: string; /** Opaque hint the UI uses to pick/parameterize the challenge view. */ uiHint?: Record; } /** * @experimental The result of a strategy attempt: * - `authenticated` → core issues a session * - `challenge` → pause the flow pending a second step * - `fail` → deny (generic public message; no user enumeration) * - `pass` → "not my credential", try the next strategy */ type AuthOutcome = { type: "authenticated"; user: AuthUser; } | { type: "challenge"; challenge: Challenge; } | { type: "fail"; reason?: string; } | { type: "pass"; }; /** @experimental A pluggable authentication strategy (app opt-in). */ interface AuthStrategy { name: string; authenticate(input: AuthInput, ctx: PluginContext): Promise; } /** @experimental Resolves a challenge given the client's response (e.g. a TOTP code). */ interface ChallengeDefinition { id: string; resolve(args: { userId: string; response: Record; }, ctx: PluginContext): Promise<{ ok: true; } | { ok: false; reason?: string; }>; } /** @experimental The names of the auth-flow hook phases. */ type AuthHookName = "beforeLogin" | "afterAuthenticate" | "afterLogin" | "beforeRegister" | "afterRegister" | "beforeLogout" | "afterLogout" | "determineUser" | "customizeClaims"; /** * @experimental Auth-flow hooks (normal contribution). Each hook may modify * (return a new value), abort (throw → generic public error), or — for * `afterAuthenticate` — return a `{ challenge }` to require a second step. */ interface AuthHooks { /** Runs before any strategy. Throw to abort. */ beforeLogin?: (input: AuthInput, ctx: PluginContext) => Promise | void; /** * After a user is identified. Return a `{ challenge }` to require a second * step, the (possibly modified) user to continue, or throw to abort. */ afterAuthenticate?: (user: AuthUser, ctx: PluginContext) => Promise | AuthUser | { challenge: Challenge; }; /** Observe-only side effects after the session is issued. */ afterLogin?: (user: AuthUser, ctx: PluginContext) => Promise | void; /** Modify registration data before the user is created. */ beforeRegister?: (data: Record, ctx: PluginContext) => Promise> | Record; /** Observe-only side effects after a user registers. */ afterRegister?: (user: AuthUser, ctx: PluginContext) => Promise | void; /** Runs before logout. */ beforeLogout?: (user: AuthUser | null, ctx: PluginContext) => Promise | void; /** Runs after logout. */ afterLogout?: (ctx: PluginContext) => Promise | void; /** * Custom current-user resolution for session/refresh. Return `null` to fall * through to core cookie/JWT resolution. */ determineUser?: (request: Request, ctx: PluginContext) => Promise | AuthUser | null; /** Add/rename JWT claims. Receives the core claims, returns the final claims. */ customizeClaims?: (claims: Record, user: AuthUser, ctx: PluginContext) => Promise> | Record; } /** * CORS Middleware * * Origin-based Cross-Origin Resource Sharing enforcement for all API responses. * Handles preflight (OPTIONS) requests and applies CORS headers to normal responses. * * Three origin modes: * - `origin: []` (default) — same-origin only, no CORS headers set * - `origin: ['*']` — wide-open access (development only), logs warning in production * - `origin: ['https://example.com', ...]` — allowlist with dynamic origin reflection * * @module middleware/cors * @since 1.0.0 * * @example * ```typescript * const cors = createCorsMiddleware({ * origin: ['https://example.com', 'https://app.example.com'], * credentials: true, * }); * * // In request pipeline: * const preflightResponse = cors.handlePreflight(request); * if (preflightResponse) return preflightResponse; * * const response = await handler(request); * return cors.applyHeaders(request, response); * ``` */ /** * Configuration for CORS middleware. * * All fields are optional with secure defaults (same-origin only). */ interface CorsConfig { /** * Allowed origins. * - `[]` (default): same-origin only — no CORS headers are set. * - `['*']`: wide-open access. Logs a warning in production. * - `['https://example.com', ...]`: allowlist with dynamic origin reflection. * * @default [] */ origin?: string[]; /** * Allowed HTTP methods for preflight responses. * * @default ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] */ methods?: string[]; /** * Headers the client is allowed to send. * * @default ["Content-Type", "Authorization"] */ allowedHeaders?: string[]; /** * Response headers exposed to client-side JavaScript. * * @default ["X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset"] */ exposedHeaders?: string[]; /** * Whether to include credentials (cookies, Authorization header). * Ignored when origin is `['*']` (CORS spec prohibits credentials with wildcard). * * @default true */ credentials?: boolean; /** * Preflight cache duration in seconds. * * @default 86400 (24 hours) */ maxAge?: number; } /** * Rate Limiting Middleware * * Provides configurable rate limiting for API endpoints to protect * against abuse and ensure fair resource usage. * * Features: * - Pluggable store interface (in-memory default, Redis-compatible) * - Separate read/write limits * - Per-collection overrides * - Skip function for admin users * - Standard rate limit headers * * @module middleware/rate-limit * @since 1.0.0 * * @example * ```typescript * // Enable rate limiting in nextly.config.ts * export default defineConfig({ * rateLimit: { * enabled: true, * readLimit: 100, // 100 GET requests per minute * writeLimit: 30, // 30 POST/PATCH/DELETE per minute * }, * }); * ``` */ /** * Result from a rate limit check. */ interface RateLimitResult { /** Whether the request is allowed */ allowed: boolean; /** Maximum requests allowed in the window */ limit: number; /** Remaining requests in current window */ remaining: number; /** Unix timestamp (ms) when the window resets */ resetTime: number; } /** * Pluggable store interface for rate limit state. * * Implement this interface to use Redis, Memcached, or other * distributed stores for rate limiting in production. * * @example * ```typescript * import Redis from 'ioredis'; * * class RedisRateLimitStore implements RateLimitStore { * private redis: Redis; * * constructor(redis: Redis) { * this.redis = redis; * } * * async increment(key: string, windowMs: number): Promise { * const now = Date.now(); * const resetTime = now + windowMs; * const count = await this.redis.incr(key); * if (count === 1) { * await this.redis.pexpire(key, windowMs); * } * return { count, resetTime }; * } * * async reset(key: string): Promise { * await this.redis.del(key); * } * } * ``` */ interface RateLimitStore { /** * Increment the request count for a key. * * @param key - Unique identifier (e.g., IP address or user ID) * @param windowMs - Time window in milliseconds * @returns Record with current count and reset time */ increment(key: string, windowMs: number): Promise; /** * Reset the request count for a key. * * @param key - Unique identifier to reset */ reset(key: string): Promise; } /** * Record returned by store increment operation. */ interface RateLimitRecord { /** Current request count in the window */ count: number; /** Unix timestamp (ms) when the window resets */ resetTime: number; } /** * Configuration for rate limiting. */ interface RateLimitConfig { /** * Enable rate limiting. * @default true */ enabled: boolean; /** * Maximum requests per window for read operations (GET). * @default 100 */ readLimit?: number; /** * Maximum requests per window for write operations (POST, PATCH, PUT, DELETE). * @default 30 */ writeLimit?: number; /** * Time window in milliseconds. * @default 60000 (1 minute) */ windowMs?: number; /** * Custom store for rate limit state. * Defaults to in-memory store if not provided. * * @example * ```typescript * import { RedisRateLimitStore } from '@nextly/ratelimit-redis'; * * rateLimit: { * enabled: true, * store: new RedisRateLimitStore(redisClient), * } * ``` */ store?: RateLimitStore; /** * Function to generate a unique key for rate limiting. * Defaults to the trusted client IP address (see `trustProxy` / * `trustedProxyIps`). Requests with no resolvable IP fall back to a * shared `unknown` bucket so anonymous traffic is still rate-limited. * * @param request - The incoming request * @returns A unique identifier string */ keyGenerator?: (request: Request) => string; /** * When true, the default keyGenerator parses * `X-Forwarded-For` (filtered through `trustedProxyIps`). When false * (default), proxy headers are ignored — direct-internet deployments * fall back to a single `unknown` bucket. Wired from * `nextly.config.ts → security.trustProxy`. * * @default false */ trustProxy?: boolean; /** * CIDR list of proxy IPs (from TRUSTED_PROXY_IPS). * Used by the default keyGenerator to walk the X-Forwarded-For chain * rightmost-first, returning the first non-proxy hop. */ trustedProxyIps?: readonly string[]; /** * Function to skip rate limiting for certain requests. * Returns true to skip rate limiting. * * **Default**: skips the admin internal API (`/admin/api/*`). The * rate limiter is meant to protect the public REST surface from * anonymous abuse; admin routes are session-authed and already gated * by `requireAdminAuth`/`requireCollectionAccess`, so applying the * same per-IP cap to admin causes false positives during normal * navigation (each admin page fires several parallel queries — * `/me`, `/dashboard/stats`, `/schema/journal`, per-collection * queries — and a handful of nav events trips the public default). * * Pass an explicit `skip` to override. If you want to rate-limit * admin too (e.g. for insider-abuse defense), wrap the default: * * @param request - The incoming request * @returns True to skip rate limiting * * @example * ```typescript * // Override: rate-limit everything, including admin * skip: () => false * * // Override: skip admin AND internal service calls * skip: (req) => { * const url = new URL(req.url); * if (url.pathname.startsWith("/admin/api/")) return true; * return req.headers.get("x-internal-key") === process.env.INTERNAL_KEY; * } * ``` */ skip?: (request: Request) => boolean | Promise; /** * Per-collection rate limit overrides. * * @example * ```typescript * collections: { * 'media': { readLimit: 50, writeLimit: 10 }, // Stricter for media * 'logs': { readLimit: 200 }, // More lenient for logs * } * ``` */ collections?: Record; /** * Custom handler for rate limit exceeded responses. * If not provided, returns a standard 429 response. * * @param request - The rate-limited request * @param result - The rate limit check result * @returns A custom Response */ handler?: (request: Request, result: RateLimitResult) => Response; } /** * In-memory rate limit store. * * Suitable for development and single-instance deployments. * For production with multiple instances, use a Redis-backed store. * * @internal */ declare class InMemoryRateLimitStore implements RateLimitStore { private hits; private cleanupInterval; constructor(); increment(key: string, windowMs: number): Promise; reset(key: string): Promise; /** * Clean up expired records to prevent memory leaks. */ private cleanup; /** * Destroy the store and stop cleanup interval. * Call this when shutting down the application. */ destroy(): void; /** * Get the current size of the store (for testing/monitoring). */ get size(): number; } /** * Create a rate limiter middleware function. * * @param config - Rate limiting configuration * @returns Middleware function that checks rate limits * * @example * ```typescript * const rateLimiter = createRateLimiter({ * enabled: true, * readLimit: 100, * writeLimit: 30, * }); * * // In route handler * const rateLimitResponse = await rateLimiter(request); * if (rateLimitResponse) { * return rateLimitResponse; // 429 Too Many Requests * } * // Continue with request handling * ``` */ declare function createRateLimiter(config: RateLimitConfig): (_request: Request) => Promise; /** * Create rate limit headers for successful requests. * * Call this after checking rate limits to add headers to the response. * * @param result - The rate limit check result * @returns Headers object to merge with response * * @example * ```typescript * const response = new Response(JSON.stringify(data), { * headers: { * 'Content-Type': 'application/json', * ...createRateLimitHeaders(rateLimitResult), * }, * }); * ``` */ declare function createRateLimitHeaders(result: RateLimitResult): Record; /** * Security Headers Middleware * * Response transformer that attaches security headers to every API response. * Headers are pre-computed at initialization time for zero per-request overhead. * * All headers are individually configurable or disableable via * `defineConfig({ security: { headers: { ... } } })`. * * @module middleware/security-headers * @since 1.0.0 * * @example * ```typescript * // Use with all defaults * const applyHeaders = createSecurityHeadersMiddleware(); * const securedResponse = applyHeaders(response); * * // Customize specific headers * const applyHeaders = createSecurityHeadersMiddleware({ * contentSecurityPolicy: "default-src 'self'", * strictTransportSecurity: false, // Disable HSTS * }); * ``` */ /** * Configuration for security response headers. * * Each header can be set to a custom string value or `false` to disable it. * Omitted headers use their secure defaults. * * @example * ```typescript * const config: SecurityHeadersConfig = { * contentSecurityPolicy: "default-src 'self'", * strictTransportSecurity: false, // Disable HSTS * }; * ``` */ interface SecurityHeadersConfig { /** * Content-Security-Policy header value. * Set to `false` to disable. * * The previous default `default-src 'none'; frame-ancestors 'none'` * was a hard "block everything" — fine on a pure * JSON response (CSP doesn't enforce on JSON) but instantly broke any * HTML response, including the admin SPA. The new default is * restrictive but lets a self-hosted Nextly admin UI run end-to-end: * * default-src 'self'; * script-src 'self'; * style-src 'self' 'unsafe-inline'; * img-src 'self' data: blob:; * font-src 'self' data:; * connect-src 'self'; * frame-ancestors 'none'; * base-uri 'self'; * form-action 'self'; * object-src 'none' * * To extend (e.g. for a CDN, analytics, or third-party fonts), pass * an explicit string here — your value replaces the default entirely. * To disable CSP entirely, set to `false`. * * @default see above */ contentSecurityPolicy?: string | false; /** * X-Content-Type-Options header value. * Set to `false` to disable. * * @default "nosniff" */ xContentTypeOptions?: string | false; /** * X-Frame-Options header value. * Set to `false` to disable. * * @default "DENY" */ xFrameOptions?: string | false; /** * Strict-Transport-Security header value. * Only applied when `NODE_ENV === 'production'` unless explicitly set. * Set to `false` to disable entirely. * * @default "max-age=31536000; includeSubDomains" */ strictTransportSecurity?: string | false; /** * Referrer-Policy header value. * Set to `false` to disable. * * @default "strict-origin-when-cross-origin" */ referrerPolicy?: string | false; /** * Permissions-Policy header value. * Set to `false` to disable. * * @default "camera=(), microphone=(), geolocation=()" */ permissionsPolicy?: string | false; } /** * Security Configuration Zod Schema * * Validates the `security` block in `defineConfig()`. Covers four sub-sections: * - `headers` — Security response headers (CSP, HSTS, X-Frame-Options, etc.) * - `cors` — Cross-Origin Resource Sharing configuration * - `uploads` — File upload MIME type restrictions * - `sanitization` — Input sanitization toggles * * All fields are optional with secure defaults applied at config resolution time. * * @module schemas/security-config * @since 1.0.0 */ /** * Validates the `security.headers` block. * * Each header accepts a custom string value or `false` to disable it. * Omitted headers use their secure defaults (see `security-headers.ts`). */ declare const SecurityHeadersConfigSchema: z.ZodObject<{ contentSecurityPolicy: z.ZodOptional]>>; xContentTypeOptions: z.ZodOptional]>>; xFrameOptions: z.ZodOptional]>>; strictTransportSecurity: z.ZodOptional]>>; referrerPolicy: z.ZodOptional]>>; permissionsPolicy: z.ZodOptional]>>; }, z.core.$strip>; /** * Validates the `security.cors` block. * * Controls Cross-Origin Resource Sharing behaviour for all API responses. * Default: same-origin only (empty `origin` array). */ declare const CorsConfigSchema: z.ZodObject<{ origin: z.ZodOptional>; methods: z.ZodOptional>; allowedHeaders: z.ZodOptional>; exposedHeaders: z.ZodOptional>; credentials: z.ZodOptional; maxAge: z.ZodOptional; }, z.core.$strip>; /** * Validates the `security.uploads` block. * * Controls MIME type restrictions and SVG serving behaviour for file uploads. */ declare const UploadSecurityConfigSchema: z.ZodObject<{ additionalMimeTypes: z.ZodOptional>; allowedMimeTypes: z.ZodOptional>; svgCsp: z.ZodOptional; }, z.core.$strip>; /** * Validates the `security.sanitization` block. * * Controls which sanitization features are active. All default to `true`. */ declare const SanitizationConfigSchema: z.ZodObject<{ enabled: z.ZodOptional; stripHtmlFromText: z.ZodOptional; validateCssValues: z.ZodOptional; validateUrlProtocols: z.ZodOptional; }, z.core.$strip>; /** * Request body / multipart size caps. Each field accepts a byte count * or a human-readable suffix (`"1mb"`, `"500kb"`). String shorthand * is parsed at runtime; the schema stays permissive. */ declare const SecurityLimitsConfigSchema: z.ZodObject<{ json: z.ZodOptional>; multipart: z.ZodOptional>; fileSize: z.ZodOptional>; fileCount: z.ZodOptional; fieldCount: z.ZodOptional; fieldSize: z.ZodOptional>; }, z.core.$strip>; /** * Validates the full `security` namespace in `defineConfig()`. * * @example * ```typescript * import { SecurityConfigSchema } from '@nextly/schemas/security-config'; * * const parsed = SecurityConfigSchema.parse({ * headers: { contentSecurityPolicy: "default-src 'self'" }, * cors: { origin: ['https://example.com'] }, * sanitization: { enabled: true }, * trustProxy: true, * limits: { multipart: "100mb" }, * }); * ``` */ /** * Per-IP rate limit on auth write endpoints (`/auth/login`, * `/auth/register`, `/auth/forgot-password`, `/auth/reset-password`). * Layered on top of the per-user lockout so an attacker can't cycle * usernames at full speed from one IP. * * The limiter shares one bucket across the four endpoints per IP so an * attacker can't reset their budget by switching paths. Set * `requestsPerHour` to `0` to disable (test/dev only). */ declare const AuthRateLimitConfigSchema: z.ZodObject<{ requestsPerHour: z.ZodOptional; windowMs: z.ZodOptional; }, z.core.$strip>; declare const SecurityConfigSchema: z.ZodObject<{ headers: z.ZodOptional]>>; xContentTypeOptions: z.ZodOptional]>>; xFrameOptions: z.ZodOptional]>>; strictTransportSecurity: z.ZodOptional]>>; referrerPolicy: z.ZodOptional]>>; permissionsPolicy: z.ZodOptional]>>; }, z.core.$strip>>; cors: z.ZodOptional>; methods: z.ZodOptional>; allowedHeaders: z.ZodOptional>; exposedHeaders: z.ZodOptional>; credentials: z.ZodOptional; maxAge: z.ZodOptional; }, z.core.$strip>>; uploads: z.ZodOptional>; allowedMimeTypes: z.ZodOptional>; svgCsp: z.ZodOptional; }, z.core.$strip>>; sanitization: z.ZodOptional; stripHtmlFromText: z.ZodOptional; validateCssValues: z.ZodOptional; validateUrlProtocols: z.ZodOptional; }, z.core.$strip>>; limits: z.ZodOptional>; multipart: z.ZodOptional>; fileSize: z.ZodOptional>; fileCount: z.ZodOptional; fieldCount: z.ZodOptional; fieldSize: z.ZodOptional>; }, z.core.$strip>>; authRateLimit: z.ZodOptional; windowMs: z.ZodOptional; }, z.core.$strip>>; trustProxy: z.ZodOptional; }, z.core.$strip>; type SecurityConfigInput = z.infer; type SecurityHeadersConfigInput = z.infer; type CorsConfigInput = z.infer; type UploadSecurityConfigInput = z.infer; type SanitizationConfigInput = z.infer; type SecurityLimitsConfigInput = z.infer; type AuthRateLimitConfigInput = z.infer; /** * Nextly Config Types * * Canonical home for the public Nextly configuration interfaces and the * pure sanitization helper that fills in defaults. User-facing modules * like `src/collections/config/define-config.ts` re-export these types * and delegate the "fill defaults" step to `sanitizeConfig()`. * * @module shared/types/config * @since 1.0.0 */ /** * TypeScript code generation configuration. * * Controls how TypeScript types are generated for collections. */ interface TypeScriptConfig { /** * Path to the generated TypeScript file. * Can be absolute or relative to the project root. * * @default './src/types/generated/payload-types.ts' */ outputFile?: string; /** * Whether to add module augmentation declarations. * When `true`, generates `declare module` blocks for type inference. * * @default true */ declare?: boolean; } /** * Database schema and migration configuration. * * Controls where Drizzle schemas and migration files are generated. */ interface DatabaseConfig { /** * Directory for generated Drizzle schema files. * Each collection generates a separate schema file. * * @default './src/db/schemas/collections' */ schemasDir?: string; /** * Directory for generated migration files. * Migrations are created via CLI commands. * * @default './src/db/migrations' */ migrationsDir?: string; /** * Path to the UI-schema manifest (`ui-schema.json`), relative to project * root. Holds UI-built collections/singles/components (spec §4.12). When the * file is absent it is treated as an empty manifest. * * @default './ui-schema.json' */ uiSchemaFile?: string; /** * When true, pending migrations run on app boot — **production only** (no-op * in development). Off by default; prefer running migrations in CI * (`nextly migrate && build`). Safe across multiple instances via the migrate * lock. May slow serverless cold starts — best for long-running * servers/containers. * * @default false */ runMigrationsOnBoot?: boolean; /** * Seconds a held migrate lock stays valid before another process may take it * over (TTL). Tune up for unusually long migrations. * * @default 900 */ migrateLockTtlSeconds?: number; } /** * Rate limiting configuration for API protection. * * Protects against abuse by limiting the number of requests * per time window. Enabled by default (100 read / 30 write per minute). * Opt out with `rateLimit: { enabled: false }`. */ interface RateLimitingConfig { /** * Enable rate limiting. * @default true */ enabled: boolean; /** * Maximum requests per window for read operations (GET). * @default 100 */ readLimit?: number; /** * Maximum requests per window for write operations (POST, PATCH, PUT, DELETE). * @default 30 */ writeLimit?: number; /** * Time window in milliseconds. * @default 60000 (1 minute) */ windowMs?: number; /** * Custom store for rate limit state. * Defaults to in-memory store if not provided. * * For production with multiple instances, use a Redis-backed store. */ store?: RateLimitStore; /** * Function to generate a unique key for rate limiting. * Defaults to using the client IP address. */ keyGenerator?: (request: Request) => string; /** * Function to skip rate limiting for certain requests. * Returns true to skip rate limiting. */ skip?: (request: Request) => boolean | Promise; /** * Per-collection rate limit overrides. */ collections?: Record; } /** * Sanitized rate limiting configuration with defaults applied. */ interface SanitizedRateLimitingConfig { /** Rate limiting is enabled */ enabled: true; /** Maximum requests per window for read operations (GET) */ readLimit: number; /** Maximum requests per window for write operations (POST, PATCH, PUT, DELETE) */ writeLimit: number; /** Time window in milliseconds */ windowMs: number; /** Custom store for rate limit state (optional) */ store?: RateLimitStore; /** Function to generate a unique key for rate limiting (optional) */ keyGenerator?: (request: Request) => string; /** Function to skip rate limiting for certain requests (optional) */ skip?: (request: Request) => boolean | Promise; /** Per-collection rate limit overrides (optional) */ collections?: Record; } /** * API key configuration. * * Controls per-key rate limiting for API key authentication. * All fields are optional — omitting the block entirely uses built-in defaults. */ interface ApiKeysConfig { /** * Per-key rate limiting settings. * Omit to use defaults (1 000 req/hour, 1-hour window). */ rateLimit?: { /** * Maximum requests an API key may make per sliding window. * Must be a positive integer. * @default 1000 */ requestsPerHour?: number; /** * Sliding window duration in milliseconds. * @default 3_600_000 (1 hour) */ windowMs?: number; }; } /** * Sanitized API key configuration with all defaults applied. */ interface SanitizedApiKeysConfig { rateLimit: { /** Maximum requests per sliding window. */ requestsPerHour: number; /** Sliding window duration in milliseconds. */ windowMs: number; }; } /** * Security configuration for Nextly. * * Controls security headers, CORS, file upload restrictions, and * input sanitization. All sub-sections are optional — secure defaults * are applied by the respective middleware factories at runtime. */ interface SecurityConfig { /** * Security response headers configuration. * * Controls CSP, X-Content-Type-Options, X-Frame-Options, HSTS, * Referrer-Policy, and Permissions-Policy headers on API responses. * Each header can be set to a custom string or `false` to disable. * Omitted headers use secure defaults. */ headers?: SecurityHeadersConfig; /** * Cross-Origin Resource Sharing (CORS) configuration. * * Default: same-origin only (no CORS headers). Use `origin: ['*']` * for development or provide an explicit allowlist for production. */ cors?: CorsConfig; /** * File upload security configuration. * * Controls MIME type allowlist and SVG serving behaviour. * Default: common safe MIME types allowed, HTML/JS blocked, * SVG served with restrictive CSP. */ uploads?: UploadSecurityConfigInput; /** * Input sanitization configuration. * * Controls HTML tag stripping for plain-text fields, CSS value * validation in rich text, and URL protocol validation. * All features enabled by default. */ sanitization?: SanitizationConfigInput; /** * Request body / multipart size caps. Each numeric field accepts * either a byte count or a human-readable suffix (`"1mb"`, * `"500kb"`). Defaults: json 1mb / multipart 50mb / fileSize 10mb / * fileCount 10 / fieldCount 50 / fieldSize 100kb. */ limits?: { json?: string | number; multipart?: string | number; fileSize?: string | number; fileCount?: number; fieldCount?: number; fieldSize?: string | number; }; /** * Per-IP rate limit on `/auth/login`, `/auth/register`, * `/auth/forgot-password`, `/auth/reset-password`. One shared * bucket per IP across all four endpoints so credential- * stuffing from a single source can't cycle paths to refill its * budget. Layered on top of the per-user lockout, not in place of. * * Set `requestsPerHour: 0` to disable the per-IP envelope (test / * dev only — leaves the deployment exposed to credential-stuffing). * * @default `{ requestsPerHour: 30, windowMs: 3_600_000 }` */ authRateLimit?: { requestsPerHour?: number; windowMs?: number; }; /** * Trust reverse-proxy headers when resolving the client IP. * * When `true`, `X-Forwarded-For` (filtered through the * `TRUSTED_PROXY_IPS` env-var CIDR list) is used to determine the * client IP for rate limiting, refresh-token binding, and audit * logging. When `false` (default), proxy headers are ignored — * direct-internet deployments fall back to a single `unknown` * bucket so an attacker cannot rotate `X-Forwarded-For` to bypass * per-IP throttles. * * Audit: closes C4 (XFF blindly trusted across rate-limit / auth flows). * * @default false */ trustProxy?: boolean; } /** * Resolved (HSL-triplet) color overrides for the admin UI. * These are derived from AdminBrandingColors after server-side hex conversion. */ interface AdminBrandingColors { /** Hex color for the primary brand color, e.g. "#6366f1". Replaces blue-500. */ primary?: string; /** Hex color for the accent brand color, e.g. "#f59e0b". Replaces cyan-500. */ accent?: string; } /** * Branding configuration for the Nextly admin UI. */ interface AdminBrandingConfig { /** * URL of a logo image to display in the sidebar. * Can be an absolute URL or a path served from your Next.js public folder. * When set, the logo image is shown instead of the text logo. * * @example "/logo.svg" or "https://cdn.example.com/logo.png" */ logoUrl?: string; /** * URL of the light-mode logo image. * Used when `logoUrl` is not set. */ logoUrlLight?: string; /** * URL of the dark-mode logo image. * Used when `logoUrl` is not set. */ logoUrlDark?: string; /** * Text label shown in the sidebar header. * Replaces the default "Nextly" label. * Also used as the `alt` attribute when `logoUrl` is set. * * @default "Nextly" */ logoText?: string; /** * URL of a custom favicon to inject into the admin page. */ favicon?: string; /** * Custom brand colors for the admin UI. * Accept 6-digit hex values only (e.g. "#6366f1"). * Foreground colors are calculated automatically to ensure WCAG AA contrast. */ colors?: AdminBrandingColors; /** * Toggle visibility of builder-related navigation (Collections/Singles/Components builders). * * This is evaluated at runtime via the `/api/admin-meta` response. * * Default behavior follows `NODE_ENV`: * - `production` => hidden * - `development` / `test` => visible * * Precedence: * 1) `admin.branding.showBuilder` (this field) * 2) `NODE_ENV` default mapping * * @default `process.env.NODE_ENV !== "production"` */ showBuilder?: boolean; } /** * Per-plugin overrides for sidebar placement and appearance. * * The host developer can override any subset of a plugin's admin config * without modifying the plugin's source code. Uses shallow merge — * only specified fields override the plugin author's defaults. */ interface PluginOverride { /** Override the plugin's sidebar placement */ placement?: AdminPlacement; /** Override the plugin's sort order */ order?: number; /** Override the position anchor for standalone plugins (which built-in section to appear after) */ after?: "dashboard" | "collections" | "singles" | "media" | "plugins" | "users" | "settings"; /** Override or extend the plugin's sidebar appearance (shallow-merged) */ appearance?: Partial; } /** * Top-level admin UI configuration for the Nextly admin panel. */ interface AdminConfig { /** Branding customizations: logo, colors, favicon. */ branding?: AdminBrandingConfig; /** * Per-plugin overrides for sidebar placement and appearance. * * Keys are plugin slugs (derived from plugin name, e.g., "form-builder"). * Values are partial overrides - only specified fields are changed. */ pluginOverrides?: Record; /** * Development-only auto-login. * * When set in dev (NODE_ENV !== "production"), the admin auth gate * issues a real session cookie for the named user on the first * /admin visit if no session is present. Same JWT-signing codepath * the real login flow uses; the only difference is the trigger. * * Hard-blocked when NODE_ENV === "production": Nextly's runtime * ignores this field with a console warning so a misconfigured prod * deploy can't silently auto-login users. * * Useful for the contributor playground and for local development * of your own Nextly project to skip the manual login step. * * @example * admin: { * devAutoLogin: { email: "dev@nextly.local", password: "dev" }, * } * * DO NOT enable in production deployments. */ devAutoLogin?: false | { /** Email address of the user to auto-login as. The user must exist (auto-login does not create users). */ email: string; /** * Optional. Used only as a label in dev logs. Auto-login does * not verify the password - it bypasses the password-check * codepath entirely since the contributor configured this. */ password?: string; }; } /** * Authentication configuration for Nextly. * * PR 5 (unified-error-system): introduces the `revealRegistrationConflict` * opt-in flag. Default behaviour is silent-success on duplicate-email * registration to prevent account enumeration via the registration form * (spec §13.2). Some products (e.g. internal admin tools where every user * is known) prefer an explicit "email already in use" message — flip this * to `true` to opt into the legacy reveal-on-conflict behaviour. */ interface AuthConfig { /** * Whether `/auth/register` should respond with an explicit * `DUPLICATE` / "An account already exists for this email." error when * the submitted email is already registered. * * Default: `false`. The registration endpoint instead returns the same * "If this email is available, we've sent a confirmation link." success * shape it would on a fresh signup, regardless of whether the email * existed. The duplicate is logged for operators. * * Set to `true` only if your threat model genuinely doesn't care about * email enumeration (e.g. a closed admin tool with controlled signup). */ revealRegistrationConflict?: boolean; /** * @experimental Opt-in auth strategies (D71). A plugin may *provide* a * strategy, but it only authenticates anyone once listed here. Strategies run * in order; the built-in `password` strategy always runs last. */ strategies?: AuthStrategy[]; } /** * Sanitized auth configuration with all defaults applied. */ interface SanitizedAuthConfig { /** Whether to reveal duplicate-email registrations on the wire. Defaults to false. */ revealRegistrationConflict: boolean; /** @experimental Opt-in auth strategies (D71); built-in password runs last. */ strategies?: AuthStrategy[]; } /** * Complete Nextly configuration interface. * * This is the main configuration object for a Nextly application, * typically exported from `nextly.config.ts` at the project root. */ interface NextlyConfig { /** Array of collection configurations. */ collections?: CollectionConfig[]; /** Array of Single configurations. */ singles?: SingleConfig[]; /** Array of Field Group configurations. */ fieldGroups?: FieldGroupConfig[]; /** User model extension configuration. */ users?: UserConfig; /** Email provider and template configuration. */ email?: EmailConfig; /** TypeScript type generation configuration. */ typescript?: TypeScriptConfig; /** Database schema and migration configuration. */ db?: DatabaseConfig; /** * Rate limiting configuration for API protection. * * Enabled by default (100 read / 30 write per minute). Opt out with `enabled: false`. */ rateLimit?: RateLimitingConfig; /** * API key authentication configuration. * * Controls per-key rate limiting applied when requests authenticate via * `Authorization: Bearer nx_live_...`. Session-based requests are unaffected. */ apiKeys?: ApiKeysConfig; /** * Authentication configuration. * * Currently exposes the `revealRegistrationConflict` opt-in flag (PR 5, * spec §13.2). Future auth-related options (token TTLs, lockout policy, * etc.) will land here so the wire surface has a single canonical home. */ auth?: AuthConfig; /** Storage plugins for cloud storage providers. */ storage?: StoragePlugin[]; /** Plugins to extend Nextly functionality. */ plugins?: PluginDefinition[]; /** @experimental Custom permissions declared by the app (seeded like plugin permissions, D36). */ permissions?: PluginPermission[]; /** Security configuration for headers, CORS, uploads, and sanitization. */ security?: SecurityConfig; /** Admin UI customization. */ admin?: AdminConfig; /** * Multilingual content configuration. Omit to keep the CMS single-language. * See docs/superpowers/specs/2026-07-08-multilingual-i18n-design.md. */ localization?: LocalizationConfig; /** Outbound webhook configuration. */ webhooks?: WebhookConfig; /** Audit and activity trail policy. */ audit?: AuditConfig; } /** * Audit trail configuration. * * Retention lives here rather than under `webhooks` because these windows bound * a record of who did what, not a delivery ledger, and an operator setting how * long activity is kept would not think to look under webhooks for it. */ interface AuditConfig { /** * How long the two trails are kept. * * Enabled by default: content activity for 90 days, auth events for 180. * `false` keeps everything forever and accepts the growth. Each window can * also be set to `false` on its own. */ retention?: AuditRetentionConfig | false; } /** * Outbound webhook configuration. * * Only retention is configurable today; endpoints are registered as rows rather * than declared here, so this is where operational policy lives rather than the * subscription list. */ interface WebhookConfig { /** * How long recorded events and delivery attempts are kept. * * Enabled by default. Events are recorded only when the install has an enabled * endpoint (or the audit seam is on), so an install with no webhooks writes * nothing to bound; when recording is active, this limits how long the rows * are kept. `false` keeps everything forever and accepts that growth. */ retention?: WebhookRetentionConfig | false; /** * Force-record every content event to the outbox even when no webhook * endpoint is configured. Off by default: with no endpoints and this off, * writes record nothing. The org-wide audit log turns this on so it captures * events regardless of delivery subscriptions. */ audit?: boolean; } /** * Normalized Nextly configuration with all defaults applied. * * This type represents the config after `sanitizeConfig()` has processed it, * with all array-valued and default-bearing fields filled in. * * Returned by `defineConfig()` and consumed by `getNextly()`, the DI * registration pipeline, and downstream services. */ interface SanitizedNextlyConfig { /** Array of collection configurations (empty array if none provided). */ collections: CollectionConfig[]; /** Array of Single configurations (empty array if none provided). */ singles: SingleConfig[]; /** Array of Component configurations (empty array if none provided). */ fieldGroups: FieldGroupConfig[]; /** User model extension configuration. Undefined if no user config provided. */ users?: UserConfig; /** Email provider and template configuration. Undefined if no email config provided. */ email?: EmailConfig; /** TypeScript configuration with defaults applied. */ typescript: Required; /** Database configuration with defaults applied. */ db: Required; /** * Rate limiting configuration. * Built automatically unless `rateLimit: { enabled: false }` is set. */ rateLimit?: SanitizedRateLimitingConfig; /** * API key configuration with defaults applied. * Undefined if omitted from defineConfig() (built-in defaults used). */ apiKeys?: SanitizedApiKeysConfig; /** * Auth configuration with defaults applied. Always present after * sanitization; the `revealRegistrationConflict` flag falls back to * `false` (silent-success on duplicate email). */ auth: SanitizedAuthConfig; /** Storage plugins for cloud storage providers (empty array if none configured). */ storage: StoragePlugin[]; /** Plugins to extend Nextly functionality (empty array if none configured). */ plugins: PluginDefinition[]; /** @experimental App-declared custom permissions (undefined if none). Seeded like plugin permissions (D36). */ permissions?: PluginPermission[]; /** Security configuration for headers, CORS, uploads, and sanitization. */ security?: SecurityConfig; /** Admin UI customization config. */ admin?: AdminConfig; /** Normalized multilingual content configuration (undefined when i18n is off). */ localization?: SanitizedLocalizationConfig; /** * Resolved webhook retention policy, or null when retention is switched off. * Always present after sanitization so consumers never re-resolve it. */ webhookRetention: ResolvedWebhookRetentionConfig | null; /** * Resolved audit-trail retention windows. * * Always a policy after sanitization, since both windows have defaults — 90 * days of content activity, 180 of auth events. Either may be `false`, which * is how keeping that trail forever is expressed; `audit: { retention: false }` * sets both. */ auditRetention: ResolvedAuditRetentionConfig; /** * Resolved delivery-log retention. * * Always a policy after sanitization, since the window has a default. The * window itself may be `false`, which is how keeping the log indefinitely is * expressed. */ emailRetention: ResolvedEmailRetentionConfig; /** * Whether the audit seam forces outbox recording regardless of endpoints. * Always present after sanitization; defaults to false. */ webhookAuditEnabled: boolean; } /** * Fill defaults on a raw `NextlyConfig` and return a `SanitizedNextlyConfig`. * * This is a pure transformation — it does **not** validate slug uniqueness, * component nesting depth, or user-field constraints. Callers that need * validation (like `defineConfig()`) should validate first and then call * this helper. * * After this step, downstream code can rely on `collections`, `singles`, * `components`, `storage`, `plugins`, `typescript`, and `db` being present * and nil-check-free. * * Validates that `apiKeys.rateLimit.requestsPerHour` and `apiKeys.rateLimit.windowMs` * are positive, because accepting those values without a bound would silently * disable rate limiting in production. * * @param config - Raw Nextly configuration * @returns Sanitized configuration with defaults applied * @throws Error if `apiKeys.rateLimit` values are invalid */ declare function sanitizeConfig(config: NextlyConfig): SanitizedNextlyConfig; /** * Dialect-Agnostic Type Definitions for Dynamic Collections * * These types define the structure for the `dynamic_collections` metadata table * and migration tracking. All dialect-specific schemas (PostgreSQL, MySQL, SQLite) * will implement these interfaces. * * @module schemas/dynamic-collections/types * @since 1.0.0 */ /** * Registry-facing webhook recording policy for the `webhooks` column. * * Only the opt-out is ever stored. Recording is the default, so `null` on the * column means "record" — which is also what a database predating the column * yields, making the column purely additive. */ interface StoredWebhookRecording { /** Whether writes to this entity are recorded to the webhook outbox. */ record: boolean; } /** * Source of the collection definition. * * - `code`: Defined in code via `defineCollection()` in a config file * - `ui`: Created through the Visual Collection Builder in Admin UI * - `built-in`: System collections provided by Nextly core * * @example * ```typescript * const source: CollectionSource = 'code'; * ``` */ type CollectionSource = "code" | "ui" | "built-in" | `plugin:${string}`; /** * Migration status for a collection's schema. * * - `synced`: Schema is in sync with the database (no pending changes) * - `pending`: Schema has changed but migration not yet created * - `generated`: Migration file has been created but not applied * - `applied`: Migration has been applied to the database (table verified to exist) * - `failed`: Migration was attempted but table creation failed * * @example * ```typescript * if (collection.migrationStatus === 'pending') { * console.log('Run `nextly migrate:create` to generate migration'); * } * if (collection.migrationStatus === 'failed') { * console.log('Table creation failed - check logs and retry'); * } * ``` */ type MigrationStatus = "synced" | "pending" | "generated" | "applied" | "failed"; /** * Labels for displaying the collection in the Admin UI. * * @example * ```typescript * const labels: CollectionLabels = { * singular: 'Post', * plural: 'Posts', * }; * ``` */ interface CollectionLabels$1 { /** Singular form of the collection name (e.g., "Post") */ singular: string; /** Plural form of the collection name (e.g., "Posts") */ plural: string; } /** * Admin UI configuration options for a collection. * * Controls how the collection appears and behaves in the Admin Panel. * * @example * ```typescript * const adminConfig: CollectionAdminConfig = { * group: 'Content', * icon: 'file-text', * useAsTitle: 'title', * pagination: { * defaultLimit: 25, * limits: [10, 25, 50, 100], * }, * }; * ``` */ interface CollectionAdminConfig { /** * Sidebar group name for organizing collections. * Collections with the same group are displayed together. */ group?: string; /** * Lucide icon name to display in the sidebar. * @see https://lucide.dev/icons */ icon?: string; /** * If true, hides the collection from the Admin sidebar. * The collection is still accessible via direct URL. */ hidden?: boolean; /** * Field name to use as the document title in the Admin UI. * This field's value is shown in breadcrumbs and relationship pickers. */ useAsTitle?: string; /** * Pagination configuration for the list view. */ pagination?: { /** Default number of items per page */ defaultLimit?: number; /** Available page size options */ limits?: number[]; }; /** * Sort order within sidebar group (lower = higher position, default: 100). */ order?: number; /** * Custom sidebar group slug. When set, item moves from its default section to this custom group. */ sidebarGroup?: string; /** * Which fields the entry list shows as columns, and in what order. * * Field keys. One that no longer exists is ignored rather than erroring, so a rename leaves a * shorter list rather than an unusable screen. */ defaultColumns?: string[]; /** * Whether this collection is provided by a plugin. */ isPlugin?: boolean; /** * Hide the admin's "New …" affordances (for machine-created collections). */ disableCreate?: boolean; /** * Preview URL configuration for content preview workflows. * * For UI-created collections, use `urlTemplate` with placeholders. * For code-first collections, use `url` function. * * @example URL template (UI collections) * ```typescript * preview: { * urlTemplate: "/preview/posts/{slug}", * label: "Preview Post", * } * ``` */ preview?: { /** * Whether this collection previews at all, decided when the config was synced. * * A code-first collection declares its preview as a FUNCTION of the entry, and no column can * hold one — so the admin cannot read the declaration back the way it reads every other * option. What it needs from the declaration is only whether a preview button belongs on the * page, and that is a boolean, so the boolean is what gets stored. * * Derived by `hasPreviewConfigured`, which is also what the resolver consults, so a stored * `true` and a resolution that reports `notConfigured` cannot disagree. The URL itself is * never stored: it depends on the entry, so it is resolved per request. */ hasPreview?: boolean; /** * URL template with field placeholders in {fieldName} format. * Used for UI-created collections where functions can't be stored. * * Read by the server when resolving a preview URL, never by the admin: interpolating it in * the browser would be a second implementation of a question the resolver already answers. * * @example "/preview/{slug}", "/api/preview?id={id}" */ urlTemplate?: string; /** * Whether to open preview in a new browser tab. * @default true */ openInNewTab?: boolean; /** * Custom label for the preview button. * @default "Preview" */ label?: string; }; /** * Custom components configuration for the admin UI. * * Allows plugins to replace default admin views (Edit, List) and * inject components at specific locations (BeforeListTable, etc.). * * Component paths use the format: `"package-name/path#ExportName"` * * @example * ```typescript * components: { * views: { * Edit: { * Component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView", * }, * }, * BeforeListTable: "@nextlyhq/plugin-form-builder/admin#CreateFormButton", * } * ``` */ components?: { /** Custom views to replace default admin views */ views?: { /** Custom Edit view component */ Edit?: { Component: string; }; /** Custom List view component */ List?: { Component: string; }; }; /** Component to render before the list table */ BeforeListTable?: string; /** Component to render after the list table */ AfterListTable?: string; /** Component to render before the edit form */ BeforeEdit?: string; /** Component to render after the edit form */ AfterEdit?: string; }; } /** * Hook type for stored hook configurations. * * Includes all standard hook types plus virtual types that map to multiple hooks: * - `beforeChange`: Runs on both create and update operations * - `afterChange`: Runs after both create and update operations * * @example * ```typescript * const hookType: StoredHookType = 'beforeChange'; * // At runtime, this maps to both 'beforeCreate' and 'beforeUpdate' * ``` */ type StoredHookType = "beforeOperation" | "beforeCreate" | "afterCreate" | "beforeUpdate" | "afterUpdate" | "beforeDelete" | "afterDelete" | "beforeRead" | "afterRead" | "beforeChange" | "afterChange"; /** * Stored configuration for a pre-built hook. * * This interface defines how hooks configured via the Admin UI are * persisted in the `dynamic_collections` table. Each hook instance * references a pre-built hook by ID and stores its configuration. * * @example * ```typescript * const storedHook: StoredHookConfig = { * hookId: 'auto-slug', * hookType: 'beforeChange', * enabled: true, * config: { * sourceField: 'title', * targetField: 'slug', * }, * order: 0, * }; * ``` */ interface StoredHookConfig { /** * Reference to the pre-built hook ID. * Must match an ID in the prebuilt hooks registry. * * @example 'auto-slug', 'audit-fields', 'unique-validation' */ hookId: string; /** * When this hook runs in the document lifecycle. * Virtual types like 'beforeChange' are mapped to actual hook types at runtime. */ hookType: StoredHookType; /** * Whether this hook is currently enabled. * Disabled hooks are stored but not executed. */ enabled: boolean; /** * Hook-specific configuration values. * The shape depends on the pre-built hook's configSchema. * * @example * ```typescript * // For auto-slug hook: * config: { sourceField: 'title', targetField: 'slug' } * * // For unique-validation hook: * config: { field: 'email', caseInsensitive: true } * ``` */ config: Record; /** * Execution order (0-based). * Hooks are executed in ascending order. * Lower numbers run first. */ order: number; } /** * Insert type for creating a new dynamic collection. * * Contains all required and optional fields for inserting a collection * into the `dynamic_collections` table. Fields with defaults (like * `schemaVersion`, `migrationStatus`) are optional on insert. * * @example * ```typescript * const newCollection: DynamicCollectionInsert = { * slug: 'posts', * labels: { singular: 'Post', plural: 'Posts' }, * tableName: 'posts', * fields: [ * { type: 'text', name: 'title', required: true }, * { type: 'richText', name: 'content' }, * ], * source: 'code', * schemaHash: 'abc123...', * }; * ``` */ interface DynamicCollectionInsert { /** Unique slug identifier (e.g., "posts", "products") */ slug: string; /** Display labels for Admin UI */ labels: CollectionLabels$1; /** Database table name for this collection */ tableName: string; /** Optional description of the collection */ /** * `null` CLEARS the stored value on update; `undefined` leaves it unchanged. The two are * distinct intents and the update path reads them that way, as it already does for * `revalidate` and `webhooks`. */ description?: string | null; /** Field configurations defining the collection schema */ fields: FieldConfig[]; /** Whether to auto-generate createdAt/updatedAt fields (default: true) */ timestamps?: boolean; /** * Whether records carry a Draft/Published status column. * When true, a `status` column ('draft' | 'published', default 'draft') is * synthesized into the collection's table. Public callers see only published * records by default; admin callers see everything. See the query-layer * `resolveStatusFilter` for how the filter is enforced. Default: false. */ status?: boolean; /** * Resolved content-versioning config for this collection, or null/undefined * when unversioned. The normalized `ResolvedVersionsConfig` produced by * `resolveVersionsConfig`, persisted on the `versions` column. */ versions?: ResolvedVersionsConfig | null; /** * Cache-revalidation config (`{ tags?, disable? }`), or null/undefined when * the collection sets none. Persisted on the `revalidate` column; the write * path reads it back to honor `disable` and merge extra `tags`. */ revalidate?: RevalidateConfig | null; /** * Webhook recording policy (`{ record: false }`), or null/undefined when the * collection uses the default of recording. Persisted on the `webhooks` * column so a Builder-authored opt-out survives a restart; for `source: 'code'` * collections the code-first `webhooks` option stays the source of truth. */ webhooks?: StoredWebhookRecording | null; /** Collection-level i18n master switch. Default: false. */ localized?: boolean; /** Admin UI configuration options */ /** * `null` CLEARS the stored block on update; `undefined` leaves it unchanged — so a config * that drops its `admin` entirely does not strand a sidebar position it no longer declares. */ admin?: CollectionAdminConfig | null; /** Where the collection was defined */ source: CollectionSource; /** * If true, the collection cannot be modified via the Admin UI. * Code-first collections are locked by default. */ locked?: boolean; /** * Path to the config file (code-first collections only). * Used for syncing and displaying source location. * @example "src/collections/posts.ts" */ configPath?: string; /** * SHA-256 hash of the fields definition. * Used for change detection during sync operations. */ schemaHash: string; /** * Schema version number, incremented on each change. * Defaults to 1 for new collections. */ schemaVersion?: number; /** * Current migration status. * Defaults to 'pending' for new collections. */ migrationStatus?: MigrationStatus; /** * Reference to the last applied migration ID. * Null for collections that haven't been migrated yet. */ lastMigrationId?: string; /** User ID who created the collection (optional) */ createdBy?: string; /** * Access control rules for CRUD operations. * * Defines who can create, read, update, and delete documents in this collection. * If not specified, all operations default to public access. * * @example * ```typescript * accessRules: { * create: { type: 'authenticated' }, * read: { type: 'public' }, * update: { type: 'owner-only' }, * delete: { type: 'role-based', allowedRoles: ['admin'] }, * } * ``` */ accessRules?: CollectionAccessRules; /** * Pre-built hooks configured via the Admin UI. * * Each hook references a pre-built hook by ID and stores its configuration. * Hooks are executed in order during document lifecycle events. * * @example * ```typescript * hooks: [ * { * hookId: 'auto-slug', * hookType: 'beforeChange', * enabled: true, * config: { sourceField: 'title', targetField: 'slug' }, * order: 0, * }, * { * hookId: 'audit-fields', * hookType: 'beforeChange', * enabled: true, * config: { createdByField: 'createdBy', updatedByField: 'updatedBy' }, * order: 1, * }, * ] * ``` */ hooks?: StoredHookConfig[]; /** * Database indexes for query performance optimization. * * Use this to define compound indexes (indexes on multiple fields). * For single-field indexes, use `index: true` on the field itself. * * @example * ```typescript * indexes: [ * { fields: ['authorId', 'createdAt'] }, * { fields: ['slug', 'locale'], unique: true }, * ] * ``` */ indexes?: IndexConfig[]; } /** * Full record type for a dynamic collection. * * Extends `DynamicCollectionInsert` with all required fields that are * set by the database (id, timestamps) or have default values. * * @example * ```typescript * const collection: DynamicCollectionRecord = { * id: 'uuid-123', * slug: 'posts', * labels: { singular: 'Post', plural: 'Posts' }, * tableName: 'posts', * fields: [...], * timestamps: true, * source: 'code', * locked: true, * schemaHash: 'abc123...', * schemaVersion: 1, * migrationStatus: 'applied', * createdAt: new Date(), * updatedAt: new Date(), * }; * ``` */ interface DynamicCollectionRecord extends DynamicCollectionInsert { /** Unique identifier (UUID or CUID) */ id: string; /** Schema version number (required, starts at 1) */ schemaVersion: number; /** Current migration status (required) */ migrationStatus: MigrationStatus; /** Whether timestamps are enabled (required, defaults to true) */ timestamps: boolean; /** Whether Draft/Published status is enabled (required, defaults to false) */ status: boolean; /** Resolved content-versioning config, or null/undefined when unversioned. */ versions?: ResolvedVersionsConfig | null; /** Whether collection-level i18n is enabled (required, defaults to false) */ localized: boolean; /** Whether collection is locked from UI edits (required) */ locked: boolean; /** When the collection was created */ createdAt: Date; /** When the collection was last updated */ updatedAt: Date; } /** * Status of a migration record (F11). * * Two-state lifecycle: rows are inserted ONLY after the apply attempt * completes, so there's no transient `pending` value on disk. Applies * to the `nextly_migrations` file-based ledger written by `nextly migrate`. * * - `applied`: Migration was successfully applied * - `failed`: Migration failed during execution */ type MigrationRecordStatus = "applied" | "failed"; /** * Structured error payload stored in `nextly_migrations.error_json` on * failed applies (F11). * * Lets operators jump straight to the failing statement instead of * grepping through a single `error_message` text blob. */ interface MigrationErrorJson { /** Database-specific error code (e.g. PG SQLSTATE `42703`). */ sqlState?: string; /** * The SQL statement that triggered the error. * * **Always undefined in F11.** PG/SQLite run all migration statements * inside a single transaction so the driver only reports the bubbled-up * error, not the specific failing statement. F15 will introduce * per-statement journaling on MySQL (which has non-transactional DDL) * and populate this field for that path. Until then, treat this as * reserved. */ statement?: string; /** Human-readable error message from the driver. */ message: string; } /** * Insert type for creating a new migration record (F11). * * Mirrors the F11 spec column shape. `id` and `appliedAt` have * defaults at the schema level so they're optional here. * * @example * ```typescript * const migration: MigrationRecordInsert = { * filename: '20260429_154500_123_add_excerpt.sql', * sha256: 'abc123…', * status: 'applied', * appliedBy: 'github-actions-12345', * durationMs: 42, * }; * ``` */ interface MigrationRecordInsert { /** * Migration filename without directory. Unique across the table. * * @example "20260429_154500_123_add_excerpt.sql" */ filename: string; /** SHA-256 of the .sql file content. 64 hex chars. */ sha256: string; /** Apply outcome. */ status: MigrationRecordStatus; /** Resolved CLI actor (env-var precedence). */ appliedBy?: string | null; /** Wall-clock apply duration in milliseconds. */ durationMs?: number | null; /** Structured error payload on failure. */ errorJson?: MigrationErrorJson | null; /** Reserved for v2 corrective rollback. Always null in v1. */ rollbackSql?: string | null; } /** * Full record type for a migration (F11). * * Extends `MigrationRecordInsert` with database-generated fields. * * @example * ```typescript * const record: MigrationRecord = { * id: 'uuid-123', * filename: '20260429_154500_123_add_excerpt.sql', * sha256: 'abc123…', * status: 'applied', * appliedBy: 'sarah@laptop', * durationMs: 42, * appliedAt: new Date(), * errorJson: null, * rollbackSql: null, * }; * ``` */ interface MigrationRecord extends MigrationRecordInsert { /** Unique identifier (UUID v4) */ id: string; /** When the migration was applied (UTC). */ appliedAt: Date; } /** * Legacy UI-collection field-definition types. * * Moved verbatim from the top-level packages/nextly/src/schemas/dynamic-collections.ts * file as part of Plan A schemas consolidation. The top-level file also declared a * stale `dynamicCollections` Drizzle table whose columns diverged from the runtime * canonical (database/schema/.ts); that duplicate table was unused by any * importer and is dropped. Only the widely-imported field-definition types survive * here. * * Long-term, these types overlap with `@nextly/collections` (FieldConfig) and * the dialect-aware `schemas/dynamic-collections/types.ts` (CollectionSource, * StoredHookConfig, etc.). A follow-up unification pass is tracked under the * Plan B work; until then, this file is the single source for the UI-builder * `FieldDefinition` / `DynamicFieldType` shape consumed across the runtime. * * @module schemas/dynamic-collections/legacy-types * @since v0.0.3-alpha (Plan A — schemas consolidation) */ type CollectionSchemaDefinition = { fields: FieldDefinition[]; }; /** * Field types for dynamic collections (UI-created collections). * * Note: This is separate from the core FieldType in collections/fields/types * to support the field surface available to UI-built collections. */ type DynamicFieldType = "text" | "textarea" | "richText" | "email" | "password" | "code" | "number" | "checkbox" | "date" | "select" | "radio" | "upload" | "relationship" | "repeater" | "group" | "json" | "component" | "chips"; type FieldDefinition = { name: string; label?: string; type: DynamicFieldType; required?: boolean; unique?: boolean; index?: boolean; private?: boolean; default?: unknown; length?: number; /** * Whether this field's values are stored per language, on the entity's * `_locales` companion rather than its main table. Absent means the * per-type default decides (text-like types localize when the entity does); * `false` pins a field to the main table, which is how the synthetic * system columns keep their place there. Persisted on the registry row and * read back by the i18n classifier, so the type has to carry it for a * declaration to survive a round trip. */ localized?: boolean; /** Storage type for number fields: whole-number `integer` (default) or exact * fixed-point `decimal` (sized by `precision`/`scale`). */ dbType?: "integer" | "decimal"; /** Total significant digits for a `decimal` number field (default 10). */ precision?: number; /** Fractional digits for a `decimal` number field (default 2). */ scale?: number; options?: { variant?: "short" | "long"; format?: "float" | "integer" | "datetime" | "date" | "time"; relationType?: "oneToOne" | "oneToMany" | "manyToOne" | "manyToMany"; target?: string; targetLabelField?: string; onDelete?: "cascade" | "set null" | "restrict" | "no action"; onUpdate?: "cascade" | "set null" | "restrict" | "no action"; junctionTable?: string; maxDepth?: number; }; /** Options for select and radio fields */ fieldOptions?: Array<{ id?: string; label: string; value: string; }>; /** Allow multiple values (for text, number, select, upload, relationship) */ hasMany?: boolean; /** Target collection slug(s) for relationship fields */ relationTo?: string | string[]; /** Maximum depth for populating related documents */ maxDepth?: number; /** Allow creating new related documents from the field */ allowCreate?: boolean; /** Allow editing related documents from the field */ allowEdit?: boolean; /** Allow drag-and-drop reordering of selected relationships (when hasMany) */ isSortable?: boolean; /** Simple filter for available related documents */ relationshipFilter?: { field: string; equals: string; }; /** MIME type filter pattern for upload fields (e.g., "image/*") */ mimeTypes?: string; /** Maximum file size in bytes for upload fields */ maxFileSize?: number; /** Display thumbnail preview for upload fields */ displayPreview?: boolean; /** Row labels for array fields (singular/plural) */ labels?: { singular?: string; plural?: string; }; /** Whether array rows should be initially collapsed */ initCollapsed?: boolean; /** Field name to use as the row label (instead of "Item 1", "Item 2") */ rowLabelField?: string; /** Nested fields for array and group field types */ fields?: FieldDefinition[]; /** Minimum rows for array fields */ minRows?: number; /** Maximum rows for array fields */ maxRows?: number; /** Maximum number of chips for chips fields */ maxChips?: number; /** Minimum number of chips for chips fields */ minChips?: number; validation?: { minLength?: number; maxLength?: number; regex?: string; min?: number; max?: number; pattern?: string; message?: string; }; admin?: { placeholder?: string; }; /** Single component slug for component fields (mutually exclusive with components) */ component?: string; /** Multiple component slugs for dynamic zone (mutually exclusive with component) */ components?: string[]; /** Whether this component field allows multiple instances (array) */ repeatable?: boolean; /** * Provenance of this field. "ui" = user-defined in the Builder; "code" = * code-first; "plugin" = contributed by a plugin (locked in the Builder, * removed from the registry when the plugin is removed). Defaults to "ui" * when absent. */ source?: "ui" | "code" | "plugin"; /** Owning plugin name when source === "plugin" (for reconcile + display). */ owner?: string; /** When true, the Builder schema editor shows this field read-only (inspect only). */ locked?: boolean; }; /** * Schema changes for a Field Group, owned in one place with the registry write they belong to. * * ## Why this exists * * A field group can be created through three transports, and only one of them made the table. The * dispatcher generated the DDL, ran it and wrote the registry row; `api/field-groups.ts` POST and * the Direct API wrote the row and returned success. The registry then described a `comp_` * table that did not exist, and every read and write to that field group failed against the * database. The reason was structural rather than careless: the code that provisions the table was * private to the dispatcher, so nothing else could reach it. * * One service owns both halves, and every transport goes through it. * * ## What it guarantees, and what it does not * * **NOT atomic.** MySQL commits DDL implicitly, so a table change and a row write cannot be made * atomic there by any ordering or any transaction. The migration engine reached the same conclusion * and says so in `field-groups/migration/steps.ts`. Promising atomicity would be a promise that * silently does not hold on one of the three supported databases. * * The DDL runs first and the row is written last, carrying the outcome the apply reached. A crash * between the two leaves a table nothing has a record of, and a DDL that FAILS still writes its row * recording `failed`. Writing the intent first would trade the first cost for a worse one: a row * persisted before the table is touched owns the slug from that moment, and nothing here can yet * finish or discard an interrupted attempt, so a create killed mid-flight would block every retry. * The ordering changes when a recovery path exists to release what an interrupted attempt claimed. * * 🔴 Everything that can REJECT a create runs before `createFieldGroup` is called, so a rejected * request neither creates a table nor writes a row. */ /** * 🔴 The schema service and the table provisioning are loaded on demand, NOT at the top of this * file. * * This service is registered in the DI container, and the registration module is imported during * boot by anything that touches the container. A static import here would pull the whole schema and * i18n machinery into that graph for every consumer, including every process that never creates a * field group. `di/register.ts` avoids exactly this with its `await import()` calls covering these * same modules, and a static import from a registration module quietly undoes that work. */ /** * How far a schema change got: the subset of migration statuses THIS SERVICE writes. * * 🔴 Narrower than the schema's set, and DERIVED from it rather than restated. `Extract` keeps the * relationship a compiler can check: a member renamed or removed from the canonical union stops * compiling here, where a hand-written copy would go on accepting a value the column no longer has. * That is the rule this file already follows elsewhere — a narrower view is derived from the richer * one, never computed alongside it. * * `diverged` is in the set because the tables were changed and the row recording it was not. It is * distinct from `failed` because the two call for OPPOSITE actions: `failed` means the change did * not happen and retrying is the repair; `diverged` means half of it did and retrying compounds it. */ type FieldGroupMigrationStatus = Extract; /** * The registry row to create, minus the one field this service owns. * * Deliberately the registry's own insert type rather than a hand-listed subset: a bespoke input * shape silently drops whatever it forgets, and that is not hypothetical — the singles equivalent * listed its fields by hand and lost three of them, which nothing in the types or the tests caught. */ type CreateFieldGroupInput = Omit; interface CreateFieldGroupResult { record: DynamicFieldGroupRecord; migrationStatus: FieldGroupMigrationStatus; } /** * A field group's metadata edit, with the physical change it implies. * * Every property is optional and `undefined` means UNTOUCHED rather than cleared, which is the * shape all three transports already spoke — a PATCH that omits `label` must not erase it. The * distinction matters most for `localized`: absent leaves the persisted value alone, while `false` * is a request to disable and moves data back out of the companion table. */ interface UpdateFieldGroupInput { slug: string; label?: string; description?: string; admin?: Record; fields?: FieldDefinition[]; localized?: boolean; /** * Who is asking, which decides whether a LOCKED field group may be written. * * A locked field group is owned by code, so only the code-first sync may change it. Defaulting to * `"ui"` means a transport that forgets to say gets the restrictive answer. */ source?: "ui" | "code"; } interface UpdateFieldGroupResult { record: DynamicFieldGroupRecord; } declare class FieldGroupMetadataService { private readonly registry; private readonly logger; /** * Optional on purpose, and it changes what this service does rather than whether it works. * * With no adapter registered the statements are generated and never run, which is the behaviour * the request handler had before this service existed. Demanding a connection here would turn a * configuration this product supports into a crash. */ private readonly adapter?; constructor(registry: FieldGroupRegistryService, logger: Logger, /** * Optional on purpose, and it changes what this service does rather than whether it works. * * With no adapter registered the statements are generated and never run, which is the behaviour * the request handler had before this service existed. Demanding a connection here would turn a * configuration this product supports into a crash. */ adapter?: DrizzleAdapter | undefined); /** * The dialect the DDL is generated for. * * Read from the adapter that will RUN the statements, never from a default. `DB_DIALECT` is * optional and falls back to `postgresql`, so an app configured with only a MySQL or SQLite URL * would otherwise have its table created as PostgreSQL. */ private get dialect(); /** * Create a field group's table and its registry row. * * The caller has already validated the input's shape. */ createFieldGroup(input: CreateFieldGroupInput): Promise; private createFieldGroupExcluded; /** * Change a field group's schema and its registry row, as one operation. * * 🔴 The founding defect of this service, still live on the UPDATE path until now. Three * transports edit a field group and only the dispatcher moved the physical schema: the mounted * route and the Direct API wrote new `fields` and a new `schema_hash` to the registry and ran no * DDL at all, because the provisioning was private to the dispatcher. The row then described * columns the table did not have. Bounded rather than silent — the registry marks * `migration_status: "pending"` and a preview introspects the live database — but two transports * performing different halves of one operation is not a difference any caller can be expected to * know about. * * Inside the exclusion for the same reasons the create is, and one more. Rendering the companion * transition consults the process-global field-type registry, which an HMR reload replaces * wholesale from inside this same exclusion; and the OLD fields this diffs against are read here * rather than passed in, so a concurrent writer cannot make the transition describe a shape that * is no longer current. */ updateFieldGroup(input: UpdateFieldGroupInput): Promise; private updateFieldGroupExcluded; /** * Re-read the row and answer whether it ALREADY carries this edit. * * Only the properties this edit actually sent are compared, and only the ones that decide the * physical shape: this is reached exclusively when the tables moved, which means `fields`, * `localized`, or both were present. Comparing anything else would let a concurrent label edit * decide whether a schema transition was recorded. * * `null` on any doubt — including a re-read that itself fails, which is the likely case when the * database is the reason the first write raised. Doubt has to resolve to "not settled": treating * an unreadable row as written would swallow a real divergence, which is the failure this whole * path exists to surface. */ private readBackSettledRow; /** * Refuse a field change whose physical consequence this path cannot carry out. * * 🔴 Two tables, and exactly one of them can be moved from here: * * - the MAIN `comp_` table receives no DDL at all on this path, so ANY difference in the * table it should have — a column added, dropped or reshaped, an index created or removed — is * unappliable and has to be refused; * - the companion `comp__locales` IS reconciled, but only by ADDING and DROPPING columns by * NAME. A localized field that keeps its name while its column changes shape emits no statement * there either, so that case is refused too. * * Every question is answered by the code that already owns it, so this guard cannot disagree with * what actually emits DDL: * * - `buildDesiredTableFromComponentFields` is the desired-state builder the diff engine runs for a * field group. It renders each column for the dialect, injects the same system columns both * sides get, carries the `idx_`/`uq_` indexes the schema service creates, and omits translatable * columns when the group is localized. Comparing the table it describes for the old fields * against the one it describes for the new is comparing what the generator would build; * - `fieldToLocalizedColumnSpec` and `ddlType` are what the companion reconciler renders its * `ADD COLUMN` from, so the text they produce is the companion column itself; * - `isFieldLocalized` is the predicate `reconcileCompanion` filters on, and it folds in the * entity flag, so a non-localized field group needs no separate branch. * * Both sides are built at the SAME localization state, deliberately. A `localized` toggle moves * columns between the two tables and `reconcileCompanion` performs exactly that move, so holding * the flag constant asks the question this guard is for — does the FIELD SET edit need DDL — and * leaves the transition to the code that applies it. * * A fourth opinion about where a column lives, or about what shape it takes, is exactly how the * three transports came to disagree in the first place. */ private assertNoUnappliableSchemaChange; /** * The registry write failed AFTER the companion transition committed. Say so, and mark the row. * * 🔴 The two halves of this operation cannot be made atomic — MySQL commits DDL implicitly, which * is why this service says so at the top of the file — so this is the state the ordering leaves * when the second half fails. The tables have the new shape and the row still describes the old * one. * * Raising the registry's own error would be worse than useless here: it reads as "the write did * not happen", which invites a retry, and a retry re-derives `wasLocalized` from the row that * still says the old value. For an enable that means seeding the companion a second time from * main-table columns the first attempt already dropped. The caller has to be told the physical * change stands. * * 🔴 The marker is a COMPARE-AND-SET against the version this edit started from, because "the * write raised" does not mean "the row went unwritten". The read-back that would settle that can * itself fail transiently, and a marker written unconditionally after two failed reads stamps * `diverged` onto a row the original write reached after all — permanently refusing schema edits * on a group that is completely fine, with its version bumped twice. Pinning the version makes * the DATABASE answer the question the reads could not: zero rows matched MEANS the row already * advanced, i.e. the original write landed, and this takes one more look before answering * success. The version advance the marker needs (an editor loaded before the transition must * fail `assertSchemaVersionMatch`) is the conditional write's own contract. * * The status write is BEST EFFORT and its failure is not raised. It is a narrow single-column * update, so it survives the failures that realistically break the full write — a rejected field * list, an oversized label, a value the driver cannot encode — and if the database is genuinely * unreachable it fails too, which is why the log carries everything needed to find the field * group without it. */ private recordUnrecordedTransition; /** The stored hash for a field set, from the one implementation the whole pipeline uses. */ private hashFields; /** * Apply the companion-table transition this edit implies, and let a failure be a failure. * * 🔴 NOT swallowed, and that is a deliberate change from the dispatcher this replaces. It logged * the error and fell through to the registry write, so a transition that failed still committed a * row saying the field group was localized with the new field set — the exact state whose * consequence the code above it describes as content stranded in the wrong table. Refusing leaves * the registry describing the old shape, which is the shape the table still has. * * The runtime rebinding happens only after the move succeeds, for the reason the create path * gives: the binding DESCRIBES the table, so describing a change that did not happen points the * running process at columns nothing created. */ private reconcileCompanion; /** * Point the runtime at the table that was just created. * * Separated from the apply so it can run after the registry write rather than with the DDL. It * describes the table to the running process; the DDL only makes it exist. */ private bindRuntimeSchema; /** * Refuse a create whose generated names the database would not store intact. * * 🔴 Checked over the NAMES rather than over the slug, because the slug is not the only input. * A field's index is named `idx__`, so the longest identifier depends on * the slug AND the longest indexed field name — and no bound on one can constrain the other. A * slug inside its limit paired with `authorId` still produces a 66-character index. * * Refused BEFORE any DDL because the failure is otherwise partial and silent-ish: the table and * the parent index are created, the field index fails, and the caller gets back a record whose * migration is recorded failed. Nothing is corrupted, but a field group exists that nothing can * query, and the request that made it reported a success shape. * * Here rather than in a transport for the same reason the ownership check is: the mounted route * bounded its slug and the other two transports did not, which is this service's founding defect * reappearing one level up. */ private assertIdentifiersFit; /** * Refuse a create whose table another field group already owns. * * Keyed on the TABLE NAME rather than the slug, because the two are not the same key: a slug is * normalised on its way to a table name, so `foo-bar` and `foo_bar` name one physical table while * looking like two free slugs. * * It has to run before the DDL rather than after. `CREATE TABLE IF NOT EXISTS` reports success * against a table that already exists, the runtime registration that follows then rebinds that * table to THIS request's fields, and only afterwards does the registry reject the duplicate — so * a refused create would leave the existing field group reading through a schema that does not * describe it, until the process restarts. * * Here rather than in a request handler because all three create transports need it and only one * of them had it. The same reason the DDL itself moved into this service. * * Two callers racing can still both pass this check; the registry table declares `table_name` * unique, so the second insert is rejected by the database rather than by this. */ private assertTableUnowned; /** Render the DDL. Separated from the apply because this half is allowed to reject the request. */ private planCreate; /** * Run the create DDL, reporting how far it got. * * Never throws: a schema change that fails is recorded rather than raised, so the caller still has * a row describing what was attempted. That is what makes the state repairable instead of lost. */ private applyCreateDdl; } /** * Direct API Namespace Context * * Defines the `NextlyContext` interface consumed by every namespace factory * under `./namespaces/`. The `Nextly` core class implements this interface * (with `@internal` accessors) so each namespace module can reach the services * and default config it needs without holding a reference to the concrete * class. * * Every member is marked `@internal`: these are implementation details of the * Direct API, not public API. Namespace modules are the only intended * consumers. * * @packageDocumentation */ /** * Services and config exposed to namespace modules by the `Nextly` core class. * * @internal */ interface NextlyContext { /** @internal */ readonly defaultConfig: DirectAPIConfig; /** @internal */ readonly formsCollectionSlug: string; /** @internal */ readonly submissionsCollectionSlug: string; /** @internal */ readonly collectionsHandler: CollectionsHandler; /** @internal */ readonly singleEntryService: SingleEntryService; /** @internal */ readonly singleRegistryService: SingleRegistryService; /** @internal */ readonly authService: AuthService; /** @internal */ readonly userAccountService: UserAccountService; /** @internal */ readonly userService: UserService; /** @internal */ readonly mediaService: MediaService; /** @internal */ readonly fieldGroupRegistryService: FieldGroupRegistryService; /** @internal */ readonly fieldGroupMetadataService: FieldGroupMetadataService; /** @internal */ readonly emailProviderService: EmailProviderService; /** @internal */ readonly emailTemplateService: EmailTemplateService; /** @internal */ readonly userFieldDefinitionService: UserFieldDefinitionService; /** @internal */ readonly emailSendService: EmailService; /** @internal */ readonly rbacRoleService: RoleService; /** @internal */ readonly rbacPermissionService: PermissionService; /** @internal */ readonly rbacRolePermissionService: RolePermissionService; /** @internal */ readonly rbacAccessControlService: RBACAccessControlService; /** @internal */ readonly apiKeyService: ApiKeyService; } /** * Direct API Users Namespace * * Factory for the `nextly.users.*` sub-namespace. Provides CRUD operations * on the users collection via the dedicated `UserService` (not the generic * collection handler). * * @packageDocumentation */ /** * Users namespace API, bound to a Nextly context. * * `ListResult` / `MutationResult` envelopes so the Direct API and * the wire API speak the same shape. */ interface UsersNamespace { find(args?: FindUsersArgs): Promise>; findOne(args?: FindOneUserArgs): Promise; findByID(args: FindUserByIDArgs): Promise; create(args: CreateUserArgs): Promise>; update(args: UpdateUserArgs): Promise>; delete(args: DeleteUserArgs): Promise>; } /** * Direct API Media Namespace * * Factory for the `nextly.media.*` sub-namespace (including the nested * `media.folders` sub-object). Wraps the `MediaService` with pagination and * error-conversion behavior. * * @packageDocumentation */ /** * Nested `media.folders` namespace. */ interface MediaFoldersNamespace { list(args?: ListFoldersArgs): Promise; create(args: CreateFolderArgs): Promise; } /** * Media namespace API, bound to a Nextly context. * * Uploads keep returning the bare `MediaFile` because they're a non-CRUD * action and the wire API does not wrap successful uploads in * `respondMutation` either. */ interface MediaNamespace { upload(args: UploadMediaArgs): Promise; find(args?: FindMediaArgs): Promise>; findByID(args: FindMediaByIDArgs): Promise; update(args: UpdateMediaArgs): Promise>; delete(args: DeleteMediaArgs): Promise>; bulkDelete(args: BulkDeleteMediaArgs): Promise; folders: MediaFoldersNamespace; } /** * Direct API Forms Namespace * * Factory for the `nextly.forms.*` sub-namespace. Delegates to the generic * `CollectionsHandler` because forms are stored in collections provided by * the `@nextlyhq/plugin-form-builder` plugin. * * @packageDocumentation */ /** * Forms namespace API, bound to a Nextly context. * * envelope. `submit()` retains its own `SubmitFormResult` shape because * it carries `success`/`redirect` semantics that don't map cleanly onto * `MutationResult`. */ interface FormsNamespace { find(args?: FindFormsArgs): Promise>>; findBySlug(args: FindFormBySlugArgs): Promise | null>; submit(args: SubmitFormArgs): Promise; submissions(args: FormSubmissionsArgs): Promise>>; } /** * Direct API Field Groups Namespace * * Factory for the `nextly.fieldGroups.*` sub-namespace. Manages field group * *definitions* (metadata + field schemas). Field group *instance* data is * automatically populated when reading collection/single entries. * * @packageDocumentation */ /** * Field groups namespace API, bound to a Nextly context. * * (`ListResult`, `MutationResult`). */ interface FieldGroupsNamespace { find(args?: FindFieldGroupsArgs): Promise>; findBySlug(args: FindFieldGroupBySlugArgs): Promise; create(args: CreateFieldGroupArgs): Promise>; update(args: UpdateFieldGroupArgs): Promise>; delete(args: DeleteFieldGroupArgs): Promise>; } /** * Dialect-Agnostic Type Definitions for Email Templates * * These types define the structure for the `email_templates` table * used to manage email templates with variable interpolation via * the Admin UI. All dialect-specific schemas (PostgreSQL, MySQL, * SQLite) will implement these interfaces. * * @module schemas/email-templates/types * @since 1.0.0 */ /** * Describes a variable available for interpolation in an email template. * * Variables are referenced in template `subject` and `htmlContent` using * `{{name}}` syntax. The `description` field documents the variable's * purpose for admin UI display. * * @example * ```typescript * const vars: EmailTemplateVariable[] = [ * { name: 'userName', description: 'The recipient user name', required: true }, * { name: 'resetLink', description: 'Password reset URL', required: true }, * { name: 'expiresIn', description: 'Token expiration time' }, * ]; * ``` */ interface EmailTemplateVariable { /** Variable name used in `{{name}}` placeholders. */ name: string; /** Human-readable description shown in the admin UI. */ description: string; /** * Whether this variable must be provided when sending the template. * @default false */ required?: boolean; } /** * Row kind discriminator for the unified `email_templates` table. * * - `template` — a message body sent to recipients (the default). * - `layout` — a wrapper whose `htmlContent` holds a `{{content}}` * placeholder where a template body is injected at send time. * - `partial` — a reusable fragment (reserved for future use). */ type EmailTemplateKind = "template" | "layout" | "partial"; /** * Insert type for creating a new email template. * * Contains all required and optional fields for inserting a template * into the `email_templates` table. Fields with defaults (like * `useLayout`, `isActive`) are optional on insert. * * @example * ```typescript * const newTemplate: EmailTemplateInsert = { * name: 'Welcome Email', * slug: 'welcome', * subject: 'Welcome to {{appName}}, {{userName}}!', * htmlContent: '

Welcome, {{userName}}!

Thanks for joining.

', * variables: [ * { name: 'userName', description: 'The new user name', required: true }, * { name: 'appName', description: 'Application name', required: true }, * ], * }; * ``` */ interface EmailTemplateInsert { /** Display name for this template (e.g., "Welcome Email", "Password Reset"). */ name: string; /** * Unique identifier slug (e.g., "welcome", "password-reset"). * Used to reference templates programmatically via the Direct API. */ slug: string; /** * Email subject line. Supports `{{variable}}` interpolation. * @example 'Reset your {{appName}} password' */ subject: string; /** * HTML body content. Supports `{{variable}}` interpolation. * When `useLayout` is true, this content is wrapped with the * shared header/footer layout. */ htmlContent: string; /** * Optional plain text fallback content. Supports `{{variable}}` interpolation. * When null, a plain text version may be auto-generated from `htmlContent`. */ plainTextContent?: string | null; /** * Inbox preview line shown after the subject. Supports `{{variable}}` * interpolation. When null/omitted, no preheader is rendered. */ preheader?: string | null; /** * Row kind. Omit for a normal message body (`template`). * @default 'template' */ kind?: EmailTemplateKind; /** * Layout row (`kind = 'layout'`) that wraps this template at send time. * When null/omitted, the default layout is used. */ layoutId?: string | null; /** * Per-template From override (e.g. `Support `). * When null/omitted, the provider / config From is used. */ fromOverride?: string | null; /** Per-template Reply-To address. When null/omitted, no Reply-To is set. */ replyTo?: string | null; /** * Available template variables with descriptions. * Displayed in the admin UI template editor for reference. * When null, no variables are documented (template may still use interpolation). */ variables?: EmailTemplateVariable[] | null; /** * Whether to wrap `htmlContent` with the shared email header/footer layout. * @default true */ useLayout?: boolean; /** * Whether this template is currently active. * Inactive templates are stored but cannot be used for sending. * @default true */ isActive?: boolean; /** * Optional provider ID to override the default email provider for this template. * When null, the system default provider is used. */ providerId?: string | null; /** * Default attachments for this template. Merged with per-send attachments * at send time (dedupe by mediaId, per-send wins). Null/omitted means * no default attachments. */ attachments?: EmailAttachmentInput[] | null; } /** * Full record type for an email template. * * Extends `EmailTemplateInsert` with all required fields that are * set by the database (id, timestamps) or have default values. * * @example * ```typescript * const template: EmailTemplateRecord = { * id: 'uuid-456', * name: 'Password Reset', * slug: 'password-reset', * subject: 'Reset your {{appName}} password', * htmlContent: '

Password Reset

Click here.

', * plainTextContent: null, * variables: [ * { name: 'resetLink', description: 'Password reset URL', required: true }, * { name: 'appName', description: 'Application name', required: true }, * ], * useLayout: true, * isActive: true, * providerId: null, * createdAt: new Date(), * updatedAt: new Date(), * }; * ``` */ interface EmailTemplateRecord extends EmailTemplateInsert { /** Unique identifier (UUID or CUID). */ id: string; /** Plain text fallback content (required on record, nullable). */ plainTextContent: string | null; /** Inbox preview line (required on record, nullable). */ preheader: string | null; /** Row kind (required on record). */ kind: EmailTemplateKind; /** Wrapping layout id (required on record, nullable). */ layoutId: string | null; /** Per-template From override (required on record, nullable). */ fromOverride: string | null; /** Per-template Reply-To (required on record, nullable). */ replyTo: string | null; /** Available template variables (required on record, nullable). */ variables: EmailTemplateVariable[] | null; /** Whether to wrap with shared layout (required on record). */ useLayout: boolean; /** Whether this template is active (required on record). */ isActive: boolean; /** Optional provider override (required on record, nullable). */ providerId: string | null; /** Default attachments (required on record, nullable). */ attachments: EmailAttachmentInput[] | null; /** When the template was created. */ createdAt: Date; /** When the template was last updated. */ updatedAt: Date; } /** * Direct API Email Namespaces * * Factories for the email-related Direct API sub-namespaces: * - `nextly.email.*` — raw send + template send * - `nextly.emailProviders.*` — provider CRUD * - `nextly.emailTemplates.*` — template CRUD + preview + layout * - `nextly.userFields.*` — user field definitions CRUD * * Each of these shares the same plumbing (error conversion, optional * pagination), so they live in one file. * * @packageDocumentation */ /** * `nextly.email.*` namespace — send raw or template-based emails. */ interface EmailNamespace { send(args: SendEmailArgs): Promise; sendWithTemplate(args: SendTemplateEmailArgs): Promise; } /** * `nextly.emailProviders.*` namespace — CRUD on provider configurations. * * `setDefault` keeps returning the bare provider record because it's a * non-CRUD action whose primary value is the resulting record itself. */ interface EmailProvidersNamespace { find(args?: FindEmailProvidersArgs): Promise>; findByID(args: FindEmailProviderByIDArgs): Promise; create(args: CreateEmailProviderArgs): Promise>; update(args: UpdateEmailProviderArgs): Promise>; delete(args: DeleteEmailProviderArgs): Promise>; setDefault(args: SetDefaultProviderArgs): Promise>; test(args: TestEmailProviderArgs): Promise<{ success: boolean; error?: string; }>; } /** * `nextly.emailTemplates.*` namespace — CRUD + preview. * * Layouts are ordinary rows with `kind: "layout"`, edited through the * same `create`/`update`/`find` calls; `preview` keeps its bespoke * shape because it is a non-CRUD action with a domain-specific return. */ interface EmailTemplatesNamespace { find(args?: FindEmailTemplatesArgs): Promise>; findByID(args: FindEmailTemplateByIDArgs): Promise; findBySlug(args: FindEmailTemplateBySlugArgs): Promise; create(args: CreateEmailTemplateArgs): Promise>; update(args: UpdateEmailTemplateArgs): Promise>; delete(args: DeleteEmailTemplateArgs): Promise>; preview(args: PreviewEmailTemplateArgs): Promise<{ subject: string; html: string; }>; } /** * `nextly.userFields.*` namespace — CRUD on user field definitions. * * `reorder` keeps its array return type because the wire-side equivalent * also returns the reordered list as a non-CRUD action. */ interface UserFieldsNamespace { find(args?: FindUserFieldsArgs): Promise>; findByID(args: FindUserFieldByIDArgs): Promise; create(args: CreateUserFieldArgs): Promise>; update(args: UpdateUserFieldArgs): Promise>; delete(args: DeleteUserFieldArgs): Promise>; reorder(args: ReorderUserFieldsArgs): Promise; } /** * Direct API RBAC Namespaces * * Factories for the RBAC-related Direct API sub-namespaces: * - `nextly.roles.*` — role CRUD + permission assignment * - `nextly.permissions.*` — permission CRUD * - `nextly.access.*` — programmatic access checks + API key validation * - `nextly.apiKeys.*` — API key lifecycle management * * These share plumbing (result shape, pagination, mapping), so they live in * one file. * * @packageDocumentation */ /** * `nextly.roles.*` namespace — role CRUD and permission assignment. * * (`ListResult`, `MutationResult`). */ interface RolesNamespace { find(args?: FindRolesArgs): Promise>; findByID(args: FindRoleByIDArgs): Promise; create(args: CreateRoleArgs): Promise>; update(args: UpdateRoleArgs): Promise>; delete(args: DeleteRoleArgs): Promise>; getPermissions(args: GetRolePermissionsArgs): Promise; setPermissions(args: SetRolePermissionsArgs): Promise; } /** * `nextly.permissions.*` namespace — permission CRUD. * */ interface PermissionsNamespace { find(args?: FindPermissionsArgs): Promise>; findByID(args: FindPermissionByIDArgs): Promise; create(args: CreatePermissionArgs): Promise>; delete(args: DeletePermissionArgs): Promise>; } /** * `nextly.access.*` namespace — programmatic access checks + API key validation. */ interface AccessNamespace { check(args: CheckAccessArgs): Promise; checkApiKey(args: CheckApiKeyArgs): Promise; } /** * `nextly.apiKeys.*` namespace — API key lifecycle management. */ interface ApiKeysNamespace { list(args?: ListApiKeysArgs): Promise; findByID(args: FindApiKeyByIDArgs): Promise; create(args: CreateApiKeyArgs): Promise<{ doc: ApiKeyMeta; key: string; }>; update(args: UpdateApiKeyArgs): Promise; revoke(args: RevokeApiKeyArgs): Promise<{ success: true; }>; } /** * Nextly Direct API class. * * Provides direct server-side access to database operations without HTTP overhead. * All methods bypass HTTP and call directly into the service layer. * * **Default Behavior:** * - `overrideAccess: true` - Access control is bypassed by default (trusted server context) * - Set `overrideAccess: false` and provide `user` context to enforce access control * * @example * ```typescript * const nextly = getNextly(); * * // Default: bypass access control (trusted server context) * // Returns ListResult = { items, meta }. * const posts = await nextly.find({ collection: 'posts' }); * posts.items; // Post[] * posts.meta.total; // number * * // Enforce access control for user-facing operations * const userPosts = await nextly.find({ * collection: 'posts', * overrideAccess: false, * user: { id: 'user-123', role: 'editor' }, * }); * ``` */ declare class Nextly implements NextlyContext { /** * Default configuration applied to all operations. * * @internal */ readonly defaultConfig: DirectAPIConfig; readonly users: UsersNamespace; readonly media: MediaNamespace; readonly forms: FormsNamespace; readonly fieldGroups: FieldGroupsNamespace; readonly email: EmailNamespace; readonly emailProviders: EmailProvidersNamespace; readonly emailTemplates: EmailTemplatesNamespace; readonly userFields: UserFieldsNamespace; readonly roles: RolesNamespace; readonly permissions: PermissionsNamespace; readonly access: AccessNamespace; readonly apiKeys: ApiKeysNamespace; /** * Create a new Nextly instance. * * @param config - Default configuration for all operations */ constructor(config?: DirectAPIConfig); /** * @experimental In-process access to plugin-contributed services (D66), keyed * by plugin name then service name — the same registry exposed to plugins as * `ctx.services.plugins`. Lazily resolved (instantiated on first access). Cast * to your service's type, or import it from the providing plugin. */ get plugins(): Record>; /** * Get the forms collection slug. * Defaults to "forms" (matching the form builder plugin default). * * @internal */ get formsCollectionSlug(): string; /** * Get the form submissions collection slug. * Defaults to "form-submissions" (matching the form builder plugin default). * * @internal */ get submissionsCollectionSlug(): string; /** @internal */ get collectionsHandler(): CollectionsHandler; /** @internal */ get singleEntryService(): SingleEntryService; /** @internal */ get singleRegistryService(): SingleRegistryService; /** Cached AuthService — not registered in the DI container. */ private _authService; /** @internal */ get authService(): AuthService; /** Cached UserAccountService — not registered in the DI container. */ private _userAccountService; /** @internal */ get userAccountService(): UserAccountService; /** @internal */ get userService(): UserService; /** @internal */ get mediaService(): MediaService; /** @internal */ get fieldGroupRegistryService(): FieldGroupRegistryService; /** @internal */ get fieldGroupMetadataService(): FieldGroupMetadataService; /** @internal */ get emailProviderService(): EmailProviderService; /** @internal */ get emailTemplateService(): EmailTemplateService; /** @internal */ get userFieldDefinitionService(): UserFieldDefinitionService; /** @internal */ get emailSendService(): EmailService; /** Cached RoleService — not registered in the DI container. */ private _rbacRoleService; /** @internal */ get rbacRoleService(): RoleService; /** Cached PermissionService — not registered in the DI container. */ private _rbacPermissionService; /** @internal */ get rbacPermissionService(): PermissionService; /** Cached RolePermissionService — not registered in the DI container. */ private _rbacRolePermissionService; /** @internal */ get rbacRolePermissionService(): RolePermissionService; /** @internal */ get rbacAccessControlService(): RBACAccessControlService; /** @internal */ get apiKeyService(): ApiKeyService; /** * Find multiple documents in a collection. * * (`{ items, meta }`). Callers migrating from `{ docs, totalDocs, ... }`: * `result.docs` -> `result.items`, `result.totalDocs` -> `result.meta.total`. * * @throws {NextlyError} If the operation fails */ find(args: FindArgs): Promise>>; /** * Find a single document by ID. Returns `null` when not found and * `disableErrors` is `true`; otherwise throws. */ findByID(args: FindByIDArgs): Promise | null>; /** * Create a new document in a collection. * * created doc must read `result.item` (was a bare `T`). */ create(args: CreateArgs): Promise>>; /** * Update a document by ID or by `where` clause (returns the first match). * * updated doc must read `result.item` (was a bare `T`). */ update(args: UpdateArgs): Promise>>; /** * Delete a document by ID or by `where` clause. * * where `item` carries the deleted `id`. The bulk-by-where path still * returns the legacy `DeleteResult` shape (`{ deleted, ids }`) because * a multi-row delete cannot collapse to a single mutation envelope. */ delete(args: DeleteArgs): Promise | DeleteResult>; /** * Count documents matching a query. * */ count(args: CountArgs): Promise; /** Bulk-delete multiple documents by IDs (partial success pattern). */ bulkDelete(args: BulkDeleteArgs): Promise; /** * Duplicate a document (optionally applying field overrides). * * duplicated doc must read `result.item` (was a bare `T`). */ duplicate(args: DuplicateArgs): Promise>>; /** Get a Single (global) document by slug. */ findSingle(args: FindSingleArgs): Promise>; /** * Update a Single (global) document by slug. * * Returns the same `{ message, item }` envelope the collection mutations do, * so every mutation reports its outcome the same way and a post-commit hook * failure has somewhere to be reported. */ updateSingle(args: UpdateSingleArgs): Promise>>; /** Fetch the content of every registered Single. */ findSingles(args?: FindSinglesArgs): Promise; /** Verify credentials and return a signed session token. */ login(args: LoginArgs): Promise; /** Logout — no-op for the Direct API (session lives in the app). */ logout(): Promise; /** Fetch the current user's profile (requires explicit `user.id`). */ me(args: { user: UserContext$2; }): Promise; /** Update the current user's profile (name/image only). */ updateMe(args: { user: UserContext$2; data: { name?: string; image?: string; }; }): Promise; /** Register a new user with email + password. */ register(args: RegisterArgs): Promise<{ user: Record; }>; /** Change the current user's password (requires the current password). */ changePassword(args: ChangePasswordArgs & { user: UserContext$2; }): Promise<{ success: true; }>; /** Initiate password reset (always returns success to avoid leaking emails). */ forgotPassword(args: ForgotPasswordArgs): Promise<{ success: true; token?: string; }>; /** Reset a user's password using a token from `forgotPassword`. */ resetPassword(args: ResetPasswordArgs): Promise<{ success: true; email?: string; }>; /** Verify a user's email using a verification token. */ verifyEmail(args: VerifyEmailArgs): Promise<{ success: true; email?: string; }>; } /** * Get the Nextly Direct API instance. * * Returns a singleton instance of the Nextly class for direct server-side * database operations. * * **Important:** `registerServices()` must be called before using this function. * * @param config - Optional configuration to apply to new instance * @returns Nextly instance * @throws {NextlyError} If services are not registered * * @example * ```typescript * import { getNextly } from 'nextly'; * * const nextly = getNextly(); * * // Find posts. Returns ListResult = { items, meta }. * const result = await nextly.find({ * collection: 'posts', * where: { status: { equals: 'published' } }, * }); * result.items; // Post[] * result.meta.total; // number * ``` */ declare function getNextly(config?: DirectAPIConfig): Nextly; /** * Module-level convenience object for Direct API operations. * * Each method lazily resolves the Nextly singleton on first call, * so it's safe to import at module scope. All methods delegate to * `getNextly()` internally. * * **Important:** Services must be initialized (via `getNextly()` from * `nextly`) before calling any method on this object. * * @example * ```typescript * import { nextly } from 'nextly'; * * // Returns ListResult = { items, meta }. * const result = await nextly.find({ * collection: 'posts', * where: { status: { equals: 'published' } }, * limit: 10, * sort: '-createdAt', * }); * result.items; // Post[] * result.meta.total; // number * ``` */ declare const nextly: { find: (args: FindArgs) => Promise>>; findByID: (args: FindByIDArgs) => Promise | null>; create: (args: CreateArgs) => Promise>>; update: (args: UpdateArgs) => Promise>>; delete: (args: DeleteArgs) => Promise>; count: (args: CountArgs) => Promise; bulkDelete: (args: BulkDeleteArgs) => Promise>; duplicate: (args: DuplicateArgs) => Promise>>; findSingle: (args: FindSingleArgs) => Promise>; updateSingle: (args: UpdateSingleArgs) => Promise>>; findSingles: (args?: FindSinglesArgs) => Promise; login: (args: LoginArgs) => Promise; logout: () => Promise; me: (args: { user: UserContext$2; }) => Promise; updateMe: (args: { user: UserContext$2; data: { name?: string; image?: string; }; }) => Promise; register: (args: RegisterArgs) => Promise<{ user: Record; }>; changePassword: (args: ChangePasswordArgs & { user: UserContext$2; }) => Promise<{ success: true; }>; forgotPassword: (args: ForgotPasswordArgs) => Promise<{ success: true; token?: string; }>; resetPassword: (args: ResetPasswordArgs) => Promise<{ success: true; email?: string; }>; verifyEmail: (args: VerifyEmailArgs) => Promise<{ success: true; email?: string; }>; users: { find: (args?: FindUsersArgs) => Promise>; findOne: (args?: FindOneUserArgs) => Promise; findByID: (args: FindUserByIDArgs) => Promise; create: (args: CreateUserArgs) => Promise>; update: (args: UpdateUserArgs) => Promise>; delete: (args: DeleteUserArgs) => Promise>; }; media: { upload: (args: UploadMediaArgs) => Promise; find: (args?: FindMediaArgs) => Promise>; findByID: (args: FindMediaByIDArgs) => Promise; update: (args: UpdateMediaArgs) => Promise>; delete: (args: DeleteMediaArgs) => Promise>; bulkDelete: (args: BulkDeleteMediaArgs) => Promise>; folders: { list: (args?: ListFoldersArgs) => Promise; create: (args: CreateFolderArgs) => Promise; }; }; forms: { find: (args?: FindFormsArgs) => Promise>>; findBySlug: (args: FindFormBySlugArgs) => Promise | null>; submit: (args: SubmitFormArgs) => Promise; submissions: (args: FormSubmissionsArgs) => Promise>>; }; fieldGroups: { find: (args?: FindFieldGroupsArgs) => Promise>; findBySlug: (args: FindFieldGroupBySlugArgs) => Promise; create: (args: CreateFieldGroupArgs) => Promise>; update: (args: UpdateFieldGroupArgs) => Promise>; delete: (args: DeleteFieldGroupArgs) => Promise>; }; email: { send: (args: SendEmailArgs) => Promise; sendWithTemplate: (args: SendTemplateEmailArgs) => Promise; }; emailProviders: { find: (args?: FindEmailProvidersArgs) => Promise>; findByID: (args: FindEmailProviderByIDArgs) => Promise; create: (args: CreateEmailProviderArgs) => Promise>; update: (args: UpdateEmailProviderArgs) => Promise>; delete: (args: DeleteEmailProviderArgs) => Promise>; setDefault: (args: SetDefaultProviderArgs) => Promise>; test: (args: TestEmailProviderArgs) => Promise<{ success: boolean; error?: string; }>; }; emailTemplates: { find: (args?: FindEmailTemplatesArgs) => Promise>; findByID: (args: FindEmailTemplateByIDArgs) => Promise; findBySlug: (args: FindEmailTemplateBySlugArgs) => Promise; create: (args: CreateEmailTemplateArgs) => Promise>; update: (args: UpdateEmailTemplateArgs) => Promise>; delete: (args: DeleteEmailTemplateArgs) => Promise>; preview: (args: PreviewEmailTemplateArgs) => Promise<{ subject: string; html: string; }>; }; userFields: { find: (args?: FindUserFieldsArgs) => Promise>; findByID: (args: FindUserFieldByIDArgs) => Promise; create: (args: CreateUserFieldArgs) => Promise>; update: (args: UpdateUserFieldArgs) => Promise>; delete: (args: DeleteUserFieldArgs) => Promise>; reorder: (args: ReorderUserFieldsArgs) => Promise; }; roles: { find: (args?: FindRolesArgs) => Promise>; findByID: (args: FindRoleByIDArgs) => Promise; create: (args: CreateRoleArgs) => Promise>; update: (args: UpdateRoleArgs) => Promise>; delete: (args: DeleteRoleArgs) => Promise>; getPermissions: (args: GetRolePermissionsArgs) => Promise; setPermissions: (args: SetRolePermissionsArgs) => Promise; }; permissions: { find: (args?: FindPermissionsArgs) => Promise>; findByID: (args: FindPermissionByIDArgs) => Promise; create: (args: CreatePermissionArgs) => Promise>; delete: (args: DeletePermissionArgs) => Promise>; }; apiKeys: { list: (args?: ListApiKeysArgs) => Promise; findByID: (args: FindApiKeyByIDArgs) => Promise; create: (args: CreateApiKeyArgs) => Promise<{ doc: ApiKeyMeta; key: string; }>; update: (args: UpdateApiKeyArgs) => Promise; revoke: (args: RevokeApiKeyArgs) => Promise<{ success: true; }>; }; access: { check: (args: CheckAccessArgs) => Promise; checkApiKey: (args: CheckApiKeyArgs) => Promise; }; }; /** * Database Lifecycle Hooks System - Type Definitions * * This module provides TypeScript type definitions for Nextly's hook system, * enabling developers to run custom logic before/after database operations. * * Inspired by modern CMS lifecycle hook patterns, adapted for Next.js 16 * and designed as an NPM package consumable API. * * @example * ```typescript * import { registerHook } from 'nextly'; * * // Hash password before creating user * registerHook('beforeCreate', 'users', async (context) => { * if (context.data?.password) { * context.data.password = await bcrypt.hash(context.data.password, 10); * } * return context.data; * }); * * // Send welcome email after user creation * registerHook('afterCreate', 'users', async (context) => { * await sendWelcomeEmail(context.data.email); * }); * ``` * * @module hooks/types * @since 1.0.0 */ /** * Available hook types for database lifecycle events. * * Hook execution order for a create operation: * 1. beforeOperation - Run before any operation (can modify args) * 2. beforeCreate - Run before validation and database insert * 3. beforeChange - Run after validation, on the data about to be written * 4. afterCreate - Run after database insert completes * * Hook execution order for an update operation: * 1. beforeOperation - Run before any operation (can modify args) * 2. beforeUpdate - Run before validation and database update * 3. beforeChange - Run after validation, on the data about to be written * 4. afterUpdate - Run after database update completes * * Hook execution order for a delete operation: * 1. beforeOperation - Run before any operation (can modify args) * 2. beforeDelete - Run before database delete * 3. afterDelete - Run after database delete completes * * Hook execution order for a read operation: * 1. beforeOperation - Run before any operation (can modify args) * 2. beforeRead - Run before database query * 3. afterRead - Run after database query completes * * `beforeCreate`/`beforeUpdate` and `beforeChange` are both pre-write, and the * difference between them is the validation gate. A `beforeCreate` handler can * supply or repair a value and have the rules applied to what it produced; a * `beforeChange` handler receives data that has already passed them, which is * what makes it the phase for deriving a stored value -- and also means what it * returns is written without being re-checked. */ declare const HOOK_TYPES: readonly ["beforeOperation", "beforeCreate", "afterCreate", "beforeUpdate", "afterUpdate", "beforeChange", "beforeDelete", "afterDelete", "beforeRead", "afterRead"]; /** * Derived from {@link HOOK_TYPES} rather than declared separately, so anything * that has to visit every phase -- clearing a collection's handlers, for one -- * can iterate the same list the type is built from. A hand-maintained array * annotated `HookType[]` type-checks perfectly while missing a phase, which is * how a newly added one went on being registered but never cleared. */ type HookType = (typeof HOOK_TYPES)[number]; /** * The phases whose handlers take a {@link HookContext}, which is every phase * except `beforeOperation`. * * `beforeOperation` handlers take a {@link BeforeOperationContext} instead and * reshape the operation's `args` rather than its `data`. Naming the rest as a * type keeps the two signatures from being registered through one another. */ type HookContextPhase = Exclude; /** * Who registered a handler, which is really the question "what will put this * back if it is removed?". * * - `"code"` -- a declaration in the config. `registerCollectionHooks` and * `registerSingleHooks` rebuild these from the config on boot AND on every * config reload, so a reload may remove them freely. Those two registrars are * the only callers entitled to claim it, and they pass it explicitly. * - `"app"` -- an imperative registration, whether through `registerHook()` or * straight into the registry the public API hands out. Nothing re-runs it: * the module holding the call is evaluated once and a config reload never * revisits it, so removing one is permanent. * - `"plugin:"` -- registered through `ctx.hooks.on` during a plugin's * `init`, which re-runs only on a full service registration and not on a * config reload. * * `"app"` is the DEFAULT, so an unannotated registration survives a reload. The * two failure modes are not symmetric: defaulting to `"code"` costs a handler * that silently disappears for good, while defaulting to `"app"` costs at worst * a stale handler that a restart clears. * * `"code"` and `"plugin:"` follow the vocabulary the webhook recording * provenance already uses, so one concept does not get two spellings. */ type HookOwner = "code" | "app" | `plugin:${string}`; /** * Context object passed to hook handlers containing operation metadata. * * The context provides all information needed for hooks to make decisions: * - Which collection is being operated on * - What operation is being performed * - The data being created/updated/deleted/read * - The user performing the operation (if authenticated) * - A shared context object for passing data between hooks * * @template T - Type of the data being operated on * * @example * ```typescript * // beforeCreate hook modifying data * const beforeCreateHook: HookHandler = async (context) => { * console.log(`Creating ${context.collection}`); * console.log(`User: ${context.user?.id}`); * * // Add auto-generated slug * const modifiedData = { * ...context.data, * slug: slugify(context.data.title) * }; * * // Store in shared context for afterCreate hook * context.context.generatedSlug = true; * * return modifiedData; * }; * * // afterCreate hook reading shared context * const afterCreateHook: HookHandler = async (context) => { * if (context.context.generatedSlug) { * console.log('Slug was auto-generated'); * } * }; * ``` */ interface HookContext { /** * Collection name (e.g., "posts", "users", "products") * * This is the slug/name of the collection being operated on. */ collection: string; /** * Operation type being performed * * Determines which CRUD operation triggered this hook. */ operation: "create" | "read" | "update" | "delete"; /** * Data being created, updated, or read * * For `before*` hooks, modifying this data will affect what gets saved to the database. * For `after*` hooks, this contains the final data that was saved/retrieved. * * **Hook Behavior:** * - `beforeCreate`: Incoming data before validation (can be modified) * - `afterCreate`: Created record from database (read-only, for side effects) * - `beforeUpdate`: Incoming changes before validation (can be modified) * - `afterUpdate`: Updated record from database (read-only) * - `beforeDelete`: Record about to be deleted (read-only) * - `afterDelete`: Deleted record data (read-only) * - `beforeRead`: Query parameters (can be modified to filter) * - `afterRead`: Fetched records (can be modified for transformation) */ data?: T; /** * Original data before changes (only for update operations) * * This allows update hooks to compare the old vs new state. * * @example * ```typescript * registerHook('afterUpdate', 'products', async (context) => { * if (context.originalData.price !== context.data.price) { * await logPriceChange(context.originalData.price, context.data.price); * } * }); * ``` */ originalData?: T; /** * User performing the operation (if authenticated) * * Contains user ID and any additional user data passed from the request. * This is `undefined` if the operation is performed without authentication. */ user?: { id: string; email?: string; [key: string]: unknown; }; /** * Shared context object for passing data between hooks * * This allows `before*` hooks to communicate with `after*` hooks * within the same request lifecycle. * * @example * ```typescript * // beforeCreate sets flag * registerHook('beforeCreate', 'posts', async (context) => { * context.context.sendNotification = true; * return context.data; * }); * * // afterCreate reads flag * registerHook('afterCreate', 'posts', async (context) => { * if (context.context.sendNotification) { * await sendNewPostNotification(context.data); * } * }); * ``` */ context: Record; /** * Request metadata and API access (optional) * * Contains HTTP request information if available (headers, query params) * and the Nextly Direct API instance for performing database operations * within hooks. * * **`req.nextly`** provides the same Direct API available via `getNextly()`, * allowing hooks to perform CRUD operations on other collections. * This allows hooks to perform CRUD operations on other collections. * * @example * ```typescript * registerHook('afterCreate', 'posts', async (context) => { * // Access Direct API via req.nextly * await context.req?.nextly?.create({ * collection: 'activity-logs', * data: { * action: 'post_created', * postId: context.data.id, * }, * }); * }); * ``` */ req?: { /** HTTP request headers */ headers?: Record; /** HTTP query parameters */ query?: Record; /** * Nextly Direct API instance. * * Provides access to all Direct API operations (`find`, `create`, `update`, * `delete`, etc.) for performing database operations within hooks. * * Allows hooks to call the full Nextly API for cross-collection operations. */ nextly?: Nextly; }; /** * Transaction-bound Drizzle executor for the write this hook participates in. * * Present only when the hook runs inside a caller-owned transaction (the * transactional bulk / entry write paths). A hook that reads the database (for * example the built-in sanitization hook, which loads field metadata) must use * it so the read runs on the transaction's own connection instead of taking a * second pooled one, which can stall against a small pool while the caller's * transaction holds the only connection. Undefined outside a transaction, where * the pooled connection is correct. */ executor?: unknown; } /** * Hook handler function signature * * Hook handlers can be synchronous or asynchronous (Promise-based). * They receive a `HookContext` and can optionally return modified data. * * **Return Value Behavior:** * - For `before*` hooks: Return value replaces `context.data` for next hook * - For `after*` hooks: Return value is ignored (use for side effects only) * * **Error Handling:** * - Throwing an error will abort the operation and rollback the transaction * - The error message will be returned to the client * * @template T - Type of the data being operated on * * @param context - Hook context with operation metadata * @returns Modified data (for before hooks) or void (for after hooks) * * @example * ```typescript * // Synchronous hook (beforeCreate) * const addTimestamp: HookHandler = (context) => { * return { * ...context.data, * publishedAt: new Date() * }; * }; * * // Asynchronous hook (afterCreate) * const notifyWebhook: HookHandler = async (context) => { * await fetch('https://webhook.example.com', { * method: 'POST', * body: JSON.stringify(context.data) * }); * // No return value needed for after hooks * }; * * // Rejecting input (beforeCreate). Throw a NextlyError, not a plain Error: * // a plain one is indistinguishable from a crash, so its message is replaced * // with a generic server-fault message before the caller sees it. * const validatePrice: HookHandler = (context) => { * if (context.data.price < 0) { * throw NextlyError.validation({ * errors: [ * { path: 'price', code: 'INVALID', message: 'Price cannot be negative.' }, * ], * }); * } * return context.data; * }; * ``` */ type HookHandler = (context: HookContext) => Promise | T | void; /** * What a FIELD-level hook is handed. * * A field hook is scoped to one field, so it is given that field's value and * name alongside the row it belongs to. This is deliberately NOT * {@link HookContext}: a collection-level hook receives the whole document as * `data` and has no single field in view, while a field hook is called once per * field and returns that field's replacement value. */ interface FieldHookContext { /** Collection or single the field belongs to. */ collection: string; /** Operation that triggered the hook. */ operation: "create" | "read" | "update" | "delete"; /** Name of the field this call is for. */ fieldName: string; /** The field's current value. */ value: T; /** The row the field belongs to, so a hook can read its siblings. */ data: Record; /** The authenticated caller, when there is one. */ user?: Record; } /** * A field-level hook. Returning a value replaces the field's value; returning * nothing leaves it as it was. */ type FieldHookHandler = (context: FieldHookContext) => Promise | T | void; /** * Operation types supported by beforeOperation hook * * These map to the CRUD operations that can trigger hooks. */ type OperationType = "create" | "read" | "update" | "delete"; /** * Arguments object for beforeOperation hook * * Contains the operation arguments that can be modified by the hook. * Different operations use different argument properties: * * - **create**: Uses `data` (the document to create) * - **read**: Uses `id` (single read) or `where` (query) * - **update**: Uses `id` and `data` (the document changes) * - **delete**: Uses `id` (the document to delete) * * @template T - Type of the data being operated on */ interface BeforeOperationArgs { /** * Data being created or updated * * For create: The full document to create * For update: The partial document with changes */ data?: T; /** * ID of the document being operated on * * For read (single): The document ID to fetch * For update: The document ID to update * For delete: The document ID to delete */ id?: string; /** * Query filter for read operations * * Used for listing/querying multiple documents. * Format follows Nextly Where query syntax. */ where?: Record; } /** * Context object passed to beforeOperation hooks * * The beforeOperation hook runs BEFORE any operation-specific hooks, * allowing you to modify operation arguments or execute side-effects * that run before an operation begins. * * **Use Cases:** * - Global logging/auditing of all operations * - Rate limiting across all operations * - Global validation or normalization * - Modifying operation arguments before they reach specific hooks * * **Execution Order:** * 1. beforeOperation (this hook) * 2. beforeCreate/beforeRead/beforeUpdate/beforeDelete * 3. Database operation * 4. afterCreate/afterRead/afterUpdate/afterDelete * * @template T - Type of the data being operated on * * @example * ```typescript * import { registerBeforeOperationHook } from 'nextly'; * * // Global logging for all operations * registerBeforeOperationHook('*', async (context) => { * console.log(`[${context.operation}] ${context.collection}`, context.args); * }); * * // Modify operation arguments * registerBeforeOperationHook('posts', async (context) => { * if (context.operation === 'create' && context.args.data) { * return { * ...context.args, * data: { ...context.args.data, source: 'api' } * }; * } * return context.args; * }); * ``` */ interface BeforeOperationContext { /** * Collection name (e.g., "posts", "users", "products") * * This is the slug/name of the collection being operated on. */ collection: string; /** * Operation type being performed * * Determines which CRUD operation is about to run. */ operation: OperationType; /** * Operation arguments that can be modified * * Contains the data, id, or where clause depending on the operation. * Returning modified args from the handler will affect the operation. */ args: BeforeOperationArgs; /** * User performing the operation (if authenticated) * * Contains user ID and any additional user data passed from the request. * This is `undefined` if the operation is performed without authentication. */ user?: HookContext["user"]; /** * Request metadata and API access (optional) * * Contains HTTP request information and the Nextly Direct API instance. * See {@link HookContext.req} for details. */ req?: HookContext["req"]; /** * Shared context object for passing data between hooks * * This allows beforeOperation to pass data to operation-specific hooks. */ context: Record; /** * Transaction-bound Drizzle executor for the write this hook participates in. * * Present only when the operation runs inside a caller-owned transaction; a * `beforeOperation` hook that reads the database must use it so the read stays * on the transaction's own connection instead of taking a second pooled one. * See {@link HookContext.executor}. Undefined outside a transaction. */ executor?: unknown; } /** * Handler function for beforeOperation hooks * * The beforeOperation handler can: * 1. Execute side-effects (logging, validation) and return void * 2. Modify operation arguments by returning modified args * 3. Throw an error to abort the operation * * **Return Value Behavior:** * - Return `void` or `undefined`: No modification, continue with original args * - Return modified `args`: Use the returned args for the operation * - Throw an error: Abort the operation * * @template T - Type of the data being operated on * * @param context - beforeOperation context with operation metadata and args * @returns Modified args object, or void for side effects only * * @example * ```typescript * // Side effect only (logging) * const logOperation: BeforeOperationHandler = async (context) => { * console.log(`Operation: ${context.operation} on ${context.collection}`); * // No return = original args unchanged * }; * * // Modify args (add field to data) * const addTimestamp: BeforeOperationHandler = async (context) => { * if (context.operation === 'create' && context.args.data) { * return { * ...context.args, * data: { ...context.args.data, operationTimestamp: new Date() } * }; * } * return context.args; * }; * * // Abort the operation. A NextlyError, not a plain one: a plain Error is * // indistinguishable from a crash, so its message and status are replaced * // with a generic server fault before the caller sees them. * const rateLimit: BeforeOperationHandler = async (context) => { * if (await isRateLimited(context.user?.id)) { * throw NextlyError.rateLimited({}); * } * }; * ``` */ type BeforeOperationHandler = (context: BeforeOperationContext) => void | Promise | BeforeOperationArgs | Promise>; /** * Define Collection Helper * * Provides the `defineCollection()` function for creating code-first collection * configurations with full TypeScript support. This is the primary API for * defining collections in TypeScript files. * * @module collections/config/define-collection * @since 1.0.0 * * @example * ```typescript * import { defineCollection, text, relationship } from '@nextly/core'; * * export default defineCollection({ * slug: 'posts', * labels: { * singular: 'Post', * plural: 'Posts', * }, * fields: [ * text({ name: 'title', required: true }), * text({ name: 'slug', unique: true }), * relationship({ name: 'author', relationTo: 'users' }), * ], * access: { * read: true, * create: ({ roles }) => roles.includes('editor') || roles.includes('admin'), * }, * }); * ``` */ /** * Display labels for a collection. * * Used in the Admin UI to display human-readable names for the collection. * If not provided, labels are auto-generated from the slug. * * @example * ```typescript * const labels: CollectionLabels = { * singular: 'Blog Post', * plural: 'Blog Posts', * }; * ``` */ interface CollectionLabels { /** * Singular form of the collection name. * Used when referring to a single document (e.g., "Create Post"). */ singular?: string; /** * Plural form of the collection name. * Used when referring to multiple documents (e.g., "All Posts"). */ plural?: string; } /** * Pagination configuration for the collection list view. * * @example * ```typescript * const pagination: CollectionPagination = { * defaultLimit: 25, * limits: [10, 25, 50, 100], * }; * ``` */ interface CollectionPagination { /** * Default number of documents per page. * @default 10 */ defaultLimit?: number; /** * Available page size options. * @default [10, 25, 50, 100] */ limits?: number[]; } /** * Preview URL configuration for content preview workflows. * * Enables editors to preview entries before publishing by generating * preview URLs that can be opened in a new tab or iframe. * * @example Function-based URL * ```typescript * const preview: CollectionPreviewConfig = { * url: (entry) => `/preview/posts/${entry.slug}`, * label: "Preview Post", * }; * ``` * * @example Conditional preview availability * ```typescript * const preview: CollectionPreviewConfig = { * url: (entry) => entry.slug ? `/preview/${entry.slug}` : null, * openInNewTab: true, * }; * ``` */ interface CollectionPreviewConfig { /** * Function to generate preview URL from entry data. * * Receives the current entry data (which may include unsaved changes) * and should return a URL string or null if preview is not available. * * @param entry - The entry data (may be unsaved/draft) * @returns Preview URL string or null if preview not available * * @example * ```typescript * url: (entry) => `/preview/posts/${entry.slug}` * url: (entry) => entry.status === 'draft' ? `/api/preview?id=${entry.id}` : null * ``` */ url: (entry: Record) => string | null; /** * Whether to open preview in a new browser tab. * @default true */ openInNewTab?: boolean; /** * Custom label for the preview button. * @default "Preview" */ label?: string; } /** * Component path string format. * * Uses the format: `"package-name/path#ExportName"` where: * - `package-name/path` is the module path (e.g., `@nextly/plugin-form-builder/admin`) * - `#ExportName` is the named export (e.g., `#FormBuilderView`) * * @example * ```typescript * // Named export from package subpath * "@nextlyhq/plugin-form-builder/admin#FormBuilderView" * * // Default export (no hash) * "@nextlyhq/plugin-form-builder/admin/FormBuilderView" * ``` */ type ComponentPath$1 = string; /** * Custom view configuration for replacing default admin views. * * @example * ```typescript * const editView: CollectionAdminViewConfig = { * Component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView", * }; * ``` */ interface CollectionAdminViewConfig { /** * Component path to the custom view component. * Format: `"package-name/path#ExportName"` */ Component: ComponentPath$1; } /** * Custom components configuration for collection admin UI. * * Allows overriding default admin views and injecting custom components * at specific locations in the admin interface. * * @example * ```typescript * const components: CollectionAdminComponents = { * views: { * Edit: { * Component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView", * }, * }, * BeforeListTable: "@nextlyhq/plugin-form-builder/admin#CreateFormButton", * }; * ``` */ interface CollectionAdminComponents { /** * Custom views to replace default admin views. */ views?: { /** * Custom Edit view component. * Replaces the default entry edit form with a custom view. */ Edit?: CollectionAdminViewConfig; /** * Custom List view component. * Replaces the default entry list view with a custom view. */ List?: CollectionAdminViewConfig; }; /** * Component to render before the list table. * Useful for custom action buttons or filters. */ BeforeListTable?: ComponentPath$1; /** * Component to render after the list table. */ AfterListTable?: ComponentPath$1; /** * Component to render before the edit form. */ BeforeEdit?: ComponentPath$1; /** * Component to render after the edit form. */ AfterEdit?: ComponentPath$1; } /** * Admin panel configuration options for a collection. * * Controls how the collection appears and behaves in the Admin UI. * * @example * ```typescript * const admin: CollectionAdminOptions = { * group: 'Content', * icon: 'FileText', * useAsTitle: 'title', * pagination: { * defaultLimit: 25, * }, * }; * ``` * * @example Custom edit view * ```typescript * const admin: CollectionAdminOptions = { * group: 'Forms', * useAsTitle: 'name', * components: { * views: { * Edit: { * Component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView", * }, * }, * }, * }; * ``` */ interface CollectionAdminOptions { /** * Group name for organizing collections in the sidebar. * Collections with the same group appear together. * * @example 'Content', 'Settings', 'Commerce' */ group?: string; /** * Which fields the admin's entry list shows as columns, and in what order. * * Omitted, the list picks its own columns. Naming them is how a collection * whose useful fields are not its first few becomes readable — a posts list * is far more use showing status and publish date than the next two fields * that happen to be declared. * * Names are field keys. A key that does not exist is ignored rather than * erroring, so a rename leaves a shorter list rather than an unusable screen. * * This mirrors the same option in the visual schema * (`schemas/_zod/ui-schema.ts`), which is what the admin has always read. * Declaring it here lets a code-first collection reach a feature the two * authoring paths were otherwise split on. */ defaultColumns?: string[]; /** * Whether this collection is provided by a plugin. * * When `true`, the collection appears in the "Plugins" section of the sidebar * instead of under "Collections". This helps users distinguish between * their own collections and plugin-provided functionality. * * @default false * * @example * ```typescript * admin: { * isPlugin: true, * group: 'Forms', // Groups within the Plugins section * } * ``` */ isPlugin?: boolean; /** * Hide the admin's "New …" affordances for this collection. * * For collections whose entries are machine-created (form submissions, * logs, webhooks), a create button offers a fiction — nothing a human * types there is a real event. The API surface is unaffected; only the * admin UI stops offering creation. * * @default false */ disableCreate?: boolean; /** * Icon identifier for the collection. * Should be a valid icon name from the icon library (e.g., Lucide). * * @example 'FileText', 'Users', 'ShoppingCart' */ icon?: string; /** * Hide the collection from the Admin UI navigation. * The collection is still accessible via direct URL and API. * * @default false */ hidden?: boolean; /** Sort order within sidebar group (lower = higher position, default: 100) */ order?: number; /** Custom sidebar group slug. When set, item moves from its default section to this custom group */ sidebarGroup?: string; /** * Field name to use as the document title in the Admin UI. * This value is displayed in lists, breadcrumbs, and relationships. * If not specified, the document ID is used. * * @example 'title', 'name', 'email' */ useAsTitle?: string; /** * Pagination configuration for the list view. */ pagination?: CollectionPagination; /** * Description text displayed below the collection title. * Use this to provide helpful context for editors. */ description?: string; /** * Preview URL configuration for content preview workflows. * * When configured, a "Preview" button appears in the entry form * that opens the generated URL in a new tab (or same tab if configured). * * @example * ```typescript * preview: { * url: (entry) => `/preview/posts/${entry.slug}`, * label: "Preview Post", * } * ``` */ preview?: CollectionPreviewConfig; /** * Custom components configuration for the admin UI. * * Allows overriding default views (Edit, List) and injecting * custom components at specific locations. * * @example * ```typescript * components: { * views: { * Edit: { * Component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView", * }, * }, * BeforeListTable: "@nextlyhq/plugin-form-builder/admin#CreateFormButton", * } * ``` */ components?: CollectionAdminComponents; } /** * Collection-level lifecycle hooks configuration. * * Hooks allow custom logic to run at specific points in a document's lifecycle. * All hooks receive a `HookContext` with operation metadata and can optionally * modify the data (for `before*` hooks). * * **Hook Execution Order:** * 1. `beforeOperation` - Before any operation begins * 2. `beforeValidate` - Before validation (create/update) * 3. `beforeChange` - Before database write (create/update) * 4. Database operation executes * 5. `afterChange` - After database write (create/update) * 6. `afterRead` - After reading from database * 7. `afterDelete` - After deletion * * @example * ```typescript * const hooks: CollectionHooks = { * beforeChange: [ * async ({ data, operation }) => { * if (operation === 'create') { * return { ...data, slug: slugify(data.title) }; * } * return data; * }, * ], * afterChange: [ * async ({ data }) => { * await invalidateCache(`posts:${data.id}`); * }, * ], * }; * ``` */ interface CollectionHooks { /** * Runs before any operation begins. * Can modify operation arguments or execute side effects. * * Handlers receive the operation's `args` -- the data, id or where clause it * is about to use -- and returning a modified set replaces them. This is the * only phase shaped that way; every other one receives `data`. */ beforeOperation?: BeforeOperationHandler[]; /** * Runs before validation during create/update. * Can transform data before validation rules are applied. */ beforeValidate?: HookHandler[]; /** * Runs before the database write during create/update. * Can transform the final data to be stored. */ beforeChange?: HookHandler[]; /** * Runs after the database write during create/update. * Useful for side effects like sending notifications. */ afterChange?: HookHandler[]; /** * Runs before reading from the database. * Can modify query parameters. */ beforeRead?: HookHandler[]; /** * Runs after reading from the database. * Can transform the data before it's returned. */ afterRead?: HookHandler[]; /** * Runs before deleting a document. * Can prevent deletion by throwing an error. */ beforeDelete?: HookHandler[]; /** * Runs after deleting a document. * Useful for cleanup or cascading deletes. */ afterDelete?: HookHandler[]; } /** * HTTP method types for custom endpoints. */ type HttpMethod = "get" | "post" | "put" | "patch" | "delete"; /** * Custom REST API endpoint configuration. * * Allows defining additional endpoints on the collection's API namespace. * Endpoints are mounted at `/api/[collection-slug]/[path]`. * * @example * ```typescript * const publishEndpoint: CustomEndpoint = { * path: '/publish', * method: 'post', * handler: async (req) => { * const { id } = await req.json(); * // Publish logic here * return Response.json({ success: true }); * }, * }; * ``` */ interface CustomEndpoint { /** * URL path for the endpoint (relative to collection namespace). * Must start with '/'. * * @example '/publish', '/export', '/bulk-update' */ path: string; /** * HTTP method for the endpoint. */ method: HttpMethod; /** * Handler function that processes the request. * Receives a standard Web API Request and returns a Response. * * @param req - The incoming HTTP request * @returns A Response object (or Promise thereof) */ handler: (req: Request) => Promise | Response; } /** * Configuration for a database index. * * Indexes improve query performance for frequently searched or sorted fields. * Use compound indexes when queries filter or sort by multiple fields together. * * @example Single compound index * ```typescript * const config: IndexConfig = { * fields: ['authorId', 'createdAt'], * }; * ``` * * @example Unique compound index * ```typescript * const config: IndexConfig = { * fields: ['slug', 'locale'], * unique: true, * name: 'slug_locale_unique', * }; * ``` */ interface IndexConfig { /** * Fields to include in the index. * * For compound indexes, the order of fields matters for query optimization. * Place the most selective (highest cardinality) fields first. * * @example ['authorId', 'createdAt'] - Optimizes queries like `WHERE authorId = ? ORDER BY createdAt` */ fields: string[]; /** * Whether this is a unique index. * * Unique indexes enforce that no two documents have the same combination * of values for the indexed fields. * * @default false */ unique?: boolean; /** * Optional custom index name. * * If not provided, a name is auto-generated using the pattern: * `{tableName}_{field1}_{field2}_idx` (or `_unique` for unique indexes) * * @example 'posts_author_status_idx' */ name?: string; } /** * Configuration for full-text search on collection entries. * * Defines which fields are searchable and how search should behave. * When not configured, search will auto-detect searchable fields * (text, textarea, email types). * * @example * ```typescript * const config: CollectionConfig = { * slug: 'posts', * search: { * searchableFields: ['title', 'content', 'excerpt'], * }, * fields: [ * { type: 'text', name: 'title' }, * { type: 'textarea', name: 'content' }, * { type: 'textarea', name: 'excerpt' }, * ], * }; * ``` */ interface SearchConfig { /** * Fields to include in search queries. * * If not specified, automatically includes all text, textarea, * and email fields from the collection schema. * * @example ['title', 'content', 'author.name'] */ searchableFields?: string[]; /** * Minimum query length required to trigger search. * Queries shorter than this will return empty results. * * @default 2 */ minSearchLength?: number; } /** * Complete collection configuration interface. * * This is the main interface for defining a collection in code. * Only `slug` and `fields` are required; all other properties have defaults. * * @example * ```typescript * const PostsConfig: CollectionConfig = { * slug: 'posts', * labels: { * singular: 'Post', * plural: 'Posts', * }, * fields: [ * { type: 'text', name: 'title', required: true }, * { type: 'textarea', name: 'content' }, * { type: 'select', name: 'status', options: [ * { label: 'Draft', value: 'draft' }, * { label: 'Published', value: 'published' }, * ]}, * ], * timestamps: true, * admin: { * group: 'Content', * useAsTitle: 'title', * }, * access: { * read: true, * create: ({ roles }) => roles.includes('editor') || roles.includes('admin'), * }, * }; * ``` */ interface CollectionConfig { /** * Unique identifier for the collection. * * Used as the database table name, API endpoint, and internal reference. * Must be: * - Unique across all collections * - URL-friendly (lowercase, no spaces) * - Not a reserved name (e.g., 'users', 'media' if used by system) * * @example 'posts', 'products', 'blog-posts', 'order_items' */ slug: string; /** * Field definitions for the collection. * * An array of field configurations that define the document structure. * Must contain at least one data-storing field. */ fields: FieldConfig[]; /** * @experimental Internal/private storage (D30). The collection stays in the * merged schema — relatable and accessible via `ctx.services` / raw `ctx.db` — * but is hidden from the content-admin navigation (implies `admin.hidden`). * Intended for plugin-private collections (e.g. a plugin's internal records). */ internal?: boolean; /** * Display labels for the Admin UI. * If not provided, labels are auto-generated from the slug. */ labels?: CollectionLabels; /** * Whether to automatically add `createdAt` and `updatedAt` timestamp fields. * * When `true`, documents will have: * - `createdAt`: Set once when document is created * - `updatedAt`: Updated on every modification * * @default true */ timestamps?: boolean; /** * Enable the Draft / Published lifecycle for this collection. * * When `true`, Nextly injects a `status` system column on the data table * (NOT NULL, default `'draft'`) and the admin entry create/edit page * shows separate Save Draft / Publish buttons. Public callers querying * with `{ status: { equals: "published" } }` will only see published * entries; drafts remain admin-only. * * Mirrors the Schema Builder's Advanced tab "Status (Draft / Published)" * toggle so code-first and Builder configurations converge on the same * underlying behaviour. * * @default false */ status?: boolean; /** * Enable content versioning (revision history) for this collection. * * Currently active: when enabled, every create/update records a restorable * snapshot of the assembled document in the global `nextly_versions` table, * written inside the same transaction as the write. Omitted = unversioned * (zero cost). * * The draft/publish split is active when the collection sets `status: true` * and versioning resolves `drafts.enabled` to true (`versions: true`, where * drafts default on, or `versions: { drafts: true }`; `versions: { drafts: * false }` and `status: true` on its own stay history-only). A status-less * update to a currently published document is then stored as a single * coalesced working draft in `nextly_versions` and the live row is left * unchanged instead of overwriting the published content; a later publish * promotes that draft onto the live row. The split additionally requires the * collection to be non-localized, every reachable component schema to resolve * and be non-localized, and no reachable field to be a password; otherwise a * status-less write goes straight to the live row as before. * * Still accepted but NOT yet enforced: `autosave` coalescing and the * `maxPerDoc` retention pruning on {@link VersionsConfig} are parsed and * persisted for forward compatibility. * * @default undefined (unversioned) */ versions?: boolean | VersionsConfig; /** * Enable multilingual content for this collection. When `true`, translatable * fields store a value per configured locale (text-like fields localize by * default; override per field with the field's `localized` flag). Requires a * `localization` block in the app config. * * @default false */ localized?: boolean; /** * Webhook recording policy for this collection. When `false` (or * `{ record: false }`), writes to this collection record NO `entry.*` event to * the webhook outbox, so nothing is ever delivered to subscribed endpoints. * Used to keep PII-bearing content — form submissions carry * `ipAddress`/`userAgent` and free-form answers — out of the delivery path. * * `emit` replaces the suppressed default event with a curated, metadata-only * one: pair `record: false` with `emit` on a PII collection so subscribers * still learn a row was created, carrying only the allowlisted fields. * * @default true (writes are recorded) */ webhooks?: boolean | { /** Whether the default `entry.*` events record. @default true */ record?: boolean; /** * Emit a curated event on create instead of relying on the default * `entry.created`. The event carries only the allowlisted `fields` * (default-deny), so a PII collection can notify subscribers of a new * row without ever shipping the row's sensitive content. */ emit?: { /** A declared webhook event type, e.g. `"form.submission.created"`. */ event: WebhookEventType; /** * Allowlist of document keys copied into the event payload. Only * these keys ship (default-deny); the created row's id is always * included in the event resource. Omit to ship id only. */ fields?: readonly string[]; }; }; /** * Admin panel configuration options. */ admin?: CollectionAdminOptions; /** * Collection-level access control. * Defines who can perform CRUD operations. * * Each operation can be: * - A **function** receiving `AccessControlContext` (user, roles, permissions) → returns boolean * - A **boolean** for simple allow/deny * - **Omitted** to fall back to database role/permission checks * * Code-defined access always takes precedence over database permissions. * Super-admin always bypasses all access checks. * * @example * ```typescript * access: { * create: ({ roles }) => roles.includes('admin') || roles.includes('editor'), * read: true, * update: ({ roles }) => roles.includes('admin') || roles.includes('editor'), * delete: ({ roles }) => roles.includes('admin'), * } * ``` */ access?: CollectionAccessControl; /** * Collection-level lifecycle hooks. * Custom logic that runs during document operations. */ hooks?: CollectionHooks; /** * Custom REST API endpoints. * Additional endpoints mounted on the collection's API namespace. */ endpoints?: CustomEndpoint[]; /** * Cache-revalidation configuration. When a Next cache adapter is registered, * every write to this collection busts the derived `nextly:*` cache tags * (collection, id, id+locale, slug), so tagged reads refresh on publish. Use * `tags` to bust extra shared tags on every write, or `disable` to opt this * collection out of automatic revalidation entirely. * * @default undefined (automatic revalidation on when a cache adapter exists) */ revalidate?: RevalidateConfig; /** * Custom metadata for plugins and extensions. * Store arbitrary data that can be accessed by hooks, plugins, or custom code. */ custom?: Record; /** * Custom database table name. * If not specified, the slug is used as the table name. * * Useful when you need a specific table name for legacy databases * or when the slug doesn't match your naming convention. * * @example 'wp_posts', 'tbl_products' */ dbName?: string; /** * Description of the collection. * Displayed in the Admin UI and used for documentation. */ description?: string; /** * Search configuration for this collection. * * Defines which fields are searchable when using the search parameter * in list queries. If not configured, search auto-detects searchable * fields (text, textarea, email types). * * @example * ```typescript * search: { * searchableFields: ['title', 'content'], * minSearchLength: 3, * } * ``` */ search?: SearchConfig; /** * Database indexes for query performance optimization. * * Use this to define compound indexes (indexes on multiple fields). * For single-field indexes, use `index: true` on the field itself. * * The `id`, `createdAt`, and `updatedAt` fields are indexed by default. * * @example * ```typescript * indexes: [ * // Compound index for filtering by author and sorting by date * { fields: ['authorId', 'createdAt'] }, * * // Unique compound index for slug + locale * { fields: ['slug', 'locale'], unique: true }, * * // Custom named index * { fields: ['status', 'publishedAt'], name: 'posts_published_idx' }, * ] * ``` */ indexes?: IndexConfig[]; /** * Whether to enable automatic input sanitization for this collection. * * When `true` (default), the global sanitization hook strips HTML tags * from plain-text fields (text, textarea, email) before database storage. * * Set to `false` to disable automatic HTML tag stripping for text fields. * Use with caution — only disable if this collection intentionally stores * HTML in text fields. * * @default true */ sanitize?: boolean; } type CollectionConfigInput = Omit & { fields: AuthorableFieldConfig[]; }; declare function defineCollection(config: CollectionConfigInput): CollectionConfig; /** * The serializable field-type catalog: one description of every built-in * field type — its key, human label, picker category, one-line hint, and * Lucide icon name. Pure data with no runtime imports, safe to consume from * the browser, the server, and plugins alike. * * This is the single source of truth the admin's field pickers render from. * Surfaces narrow it to their allowed subset by key (user profile fields, * form fields, block props); none of them redeclare what a field type is. * * Icons are carried as Lucide icon *names*: the catalog stays serializable, * and each consumer resolves names against its own icon set. */ /** * Picker grouping, in display order: Basic → Advanced → Media → Relational → * Structured. Categories render as sticky headers; entries appear under * their header in catalog order. */ type FieldTypeCategory = "Basic" | "Advanced" | "Media" | "Relational" | "Structured"; /** * One catalog row describing a field type for pickers and docs. Generic over * the key so a surface's own types (see the user-surface entries below) carry * their narrower union end to end. */ interface FieldTypeCatalogEntry { /** The type key field instances reference. */ type: T; /** Human label shown in pickers. */ label: string; /** Picker grouping. */ category: FieldTypeCategory; /** One-line description shown under the label. */ hint: string; /** Lucide icon name, resolved by each consumer's icon set. */ icon: string; } /** Every built-in field type, described once. */ declare const FIELD_TYPE_CATALOG: readonly FieldTypeCatalogEntry[]; /** * The admin surfaces a field type can appear on. Each surface narrows the * visible type set independently: what a picker shows resolves as the * surface's own capability set ∩ the type's declared surfaces ∩ the host's * excludes — every level can only remove types, never force one in. Lives * here beside the surface catalogs so both the built-in surface types and * plugin-declared `surfaces` reference one definition. */ type FieldSurface = "entries" | "users" | "forms" | "blocks"; /** * The surface a plugin field type targets when its author declares none. Shared * so the server-side gate and the client-side picker projection never diverge. */ declare const DEFAULT_FIELD_SURFACES: readonly FieldSurface[]; /** * Field types that exist only on specific admin surfaces. They are NOT part * of the canonical `FieldType` union: a collection cannot declare them, so * they can never reach the schema pipeline's column mappers. Their storage * is either text with validation semantics (url, phone, time, hidden) or the * surface's own blob handling (file inside a form's JSON). */ type UserSurfaceFieldType = "url" | "phone"; /** Field types that exist only on the form-builder surface. */ type FormSurfaceFieldType = "url" | "phone" | "file" | "time" | "hidden"; /** The user-profile surface's field types: flat scalars plus url/phone. */ type UserFieldCatalogType = "text" | "textarea" | "number" | "email" | "url" | "phone" | "select" | "radio" | "checkbox" | "date"; /** * The user-profile picker's catalog: the flat-scalar subset of the shared * catalog with the two user-surface types slotted beside email, where a * profile author expects contact-shaped fields together. */ declare const USER_FIELD_TYPE_CATALOG: readonly FieldTypeCatalogEntry[]; /** The form-builder surface's field types: flat inputs plus its own five. */ type FormFieldCatalogType = "text" | "textarea" | "number" | "email" | "url" | "phone" | "select" | "radio" | "checkbox" | "date" | "time" | "file" | "hidden"; /** * The form-builder picker's catalog: the flat-input subset of the shared * catalog plus the form-surface types — url/phone beside email (contact * shapes together, matching the user surface), time beside date, and * file/hidden appended in their own categories. Form fields live in the * form's JSON blob, so none of these touch the schema pipeline. */ declare const FORM_FIELD_TYPE_CATALOG: readonly FieldTypeCatalogEntry[]; /** * The block-prop surface's field types: everything a collection can declare * except two deliberate exclusions. * * - `password` is excluded because a block document is public page content * rendered to every visitor, so a secret must never be authorable as a * block prop. * - `component` is excluded because reusable composition inside a block * document happens through slots and component-instance nodes; admitting the * component field type as well would give one concept two storage shapes. * - `blocks` is excluded because a prop holding a whole nested document is * what slots already express, and nesting documents inside documents would * put two migration boundaries in one value. * * Link-shaped props keep using `text` until the dedicated link picker joins * the catalog with its admin component. */ type BlockFieldCatalogType = Exclude; /** Every block-prop field type, in catalog order. */ declare const BLOCK_FIELD_TYPES: readonly BlockFieldCatalogType[]; /** * The block-prop picker's catalog: the shared catalog narrowed to the types a * block prop may declare. Unlike the user and form surfaces it adds no * surface-only types, so every entry here maps to a real `FieldConfig`. */ declare const BLOCK_FIELD_TYPE_CATALOG: readonly FieldTypeCatalogEntry[]; /** Whether a field type may be declared as a block prop. */ declare function isBlockFieldType(type: string): type is BlockFieldCatalogType; /** * The storage shapes a plugin-contributed field type can persist as. A plugin * type is not a member of the built-in union, so everything that needs to * reason about its values (validation, bindings) goes through the primitive it * declared. */ type FieldStoragePrimitive = "text" | "longText" | "boolean" | "number" | "timestamp" | "json"; /** * The built-in field type each storage primitive behaves as. A plugin type * validates by its primitive's rules and binds by its primitive's value kind, * while its own admin component renders it. */ declare const STORAGE_PRIMITIVE_AS_FIELD_TYPE: Readonly>; /** * The value shapes a binding can carry. A binding connects a data field to a * block prop, so both sides are described in this one vocabulary and * compatibility is a set membership test rather than a per-pair table. */ type BindingValueKind = "text" | "richText" | "number" | "boolean" | "date" | "media" | "option" | "reference" | "list" | "json"; /** * The kind of value a field of each type produces when it is a binding * SOURCE. `null` means the type cannot be bound from at all: `password` never * leaves the server, `component` and `group` are containers whose parts are * bound individually, and a `repeater` is a to-many collection that a loop * iterates rather than a binding flattens. */ declare const FIELD_TYPE_BINDING_KIND: Readonly>; /** * The validation rules a field can carry, named as `FieldValidation` spells * them. `FieldValidation` permits every member on every field, because it is * one record shared by all types; which of them MEAN anything for a given type * is the separate question this vocabulary exists to answer. */ type FieldValidationRule = "required" | "pattern" | "message" | "minLength" | "maxLength" | "min" | "max" | "minRows" | "maxRows"; /** * Which validation rules are meaningful for each field type. * * A length bound says nothing about a checkbox and a numeric bound says nothing * about a string, so an editor that offers every rule everywhere invites values * that nothing will ever read. `Record` makes the map exhaustive * by construction: a new member of the union does not compile until it states * its rules, which is the property that keeps this from drifting behind the * types the way a hand-kept list does. * * `required` is meaningful for every type and is listed for every type, because * this map describes the field rather than any one editor's layout. A surface * that presents requiredness through its own control renders the rest of the * list and skips this member. */ declare const FIELD_TYPE_VALIDATION_RULES: Readonly>; /** * The rules meaningful for a field, including one contributed by a plugin. * * A plugin type is not a member of `FieldType`, so it cannot key the map. It * declares the primitive it persists as, and `STORAGE_PRIMITIVE_AS_FIELD_TYPE` * already names the built-in type that primitive behaves as — so a plugin type * inherits that type's rules rather than needing its own entry, and a plugin * shipped after this code was written is covered without editing anything here. */ declare function validationRulesForFieldType(type: string, pluginStorage?: FieldStoragePrimitive): readonly FieldValidationRule[]; /** * The value kinds each block-prop type accepts from a binding. This map IS the * bindability rule: a prop's binding affordance is derived from its declared * TYPE and never from a per-block opt-in, so a new block gets binding support * on every compatible prop the moment it is written. * * String-valued props accept numbers and dates because a binding carries an * optional formatter that renders them as text. Rich text accepts only rich * text: its stored value is structured editor content, and a plain string * would not survive the round trip. Structured props (`repeater`, `group`) are * composed rather than bound, so they accept nothing. */ declare const BINDABLE_KINDS: Readonly>; /** * One end of a candidate binding: a field being bound from, or a block prop * being bound into. * * `hasMany` matters because a multi-valued field produces an array, so type * agreement alone does not make two ends compatible. `storage` describes a * plugin-contributed type, which is not a member of the built-in union but * persists as one of the primitives; supplying it lets a plugin type take part * in bindings on the same terms as a built-in. `relationTo` carries the * collection identity of a reference or media endpoint, which the value kind * alone does not express. */ interface BindingEndpoint { type: string; hasMany?: boolean; storage?: FieldStoragePrimitive; relationTo?: string | string[]; } /** * The value kind an endpoint produces when it is a binding source, or `null` * when it cannot be bound from. */ declare function bindingKindOf(endpoint: BindingEndpoint): BindingValueKind | null; /** Whether a block prop can be bound to a data field at all. */ declare function isBindablePropType(prop: BindingEndpoint): boolean; /** * Whether a field can be bound into a block prop. Pickers use this to filter * the field list they offer, so a user is never shown a binding that the * renderer would then have to coerce. * * Both the value kind and the cardinality must agree: a multi-valued source * produces an array, which a single-valued prop cannot render, and a * single-valued source cannot fill a prop that expects a list. * * Reference and media endpoints must also agree on the collections they point * at, and on how a reference to them is stored. Binding does not rewrite a * reference, so a source that can yield a document the prop does not relate * to would put an unresolvable value in the prop even though both ends are of * kind `reference`, and a source whose target arity differs stores a shape the * prop cannot read. The checks apply only when both ends name their targets, * since an endpoint that omits them is saying nothing about collection * identity rather than claiming to accept any. */ declare function canBindFieldToProp(source: BindingEndpoint, prop: BindingEndpoint): boolean; /** Look up one catalog entry by its type key. */ declare function getFieldTypeCatalogEntry(type: FieldType): FieldTypeCatalogEntry | undefined; /** * Narrow the catalog to a surface's allowed types, preserving catalog order. * The result's `type` is narrowed to the requested subset, so a surface with * its own type union (e.g. user profile fields) keeps it end to end. */ declare function narrowFieldTypeCatalog(types: readonly T[]): Array; /** * What a mail provider has to tell Nextly about itself. * * A provider used to be a `type` string plus a factory, which was enough to * dispatch a send and not enough for anything else: the REST layer validated * against a hardcoded union, the admin rendered one of three bespoke forms, and * redaction guessed which values were secret from their key names. Each of * those had to be edited to add a provider, so the extension point existed * without being reachable. * * A definition carries the answers instead. Core stops knowing provider names. * * @module domains/email/provider-definition */ /** * Longest provider type id that every dialect can store. * * Postgres and MySQL declare `email_providers.type` as `varchar(50)` while * SQLite is unbounded text, so a longer namespaced id registers fine, works on * SQLite, and is rejected or silently truncated on the other two. Truncation is * the worse half: the stored type would no longer match any registered * provider, leaving a row nothing can build an adapter for. * * Enforced at registration so the failure names the plugin at boot, rather than * appearing as a database error the first time someone saves a provider. */ declare const MAX_EMAIL_PROVIDER_TYPE_LENGTH = 50; /** * How one configuration value is entered and treated. * * Serializable on purpose: this is the only part of a definition that crosses * to the browser, so a provider can describe its form without shipping React, * depending on admin internals, or being renderable in only one place. */ interface EmailProviderConfigField { /** Key within the stored `configuration` object. */ name: string; /** Field label shown to whoever is configuring the provider. */ label: string; /** Which control to render, and how to treat the value. */ kind: "text" | "password" | "number" | "boolean" | "select"; required?: boolean; /** Pre-filled when adding a provider of this type. */ default?: string | number | boolean; /** One line under the field. Say what it is for, not what it is called. */ help?: string; placeholder?: string; /** Choices for `kind: "select"`. Ignored otherwise. */ options?: ReadonlyArray<{ value: string; label: string; }>; /** * Marks a credential. * * Redaction reads this rather than inferring from the key name, which * mistakes a field called `credential` for public and a harmless one called * `token` for secret. Declaring it is the only way to be right about a name * core has never seen. */ secret?: boolean; /** * Hints so the form can object before a round trip. * * Deliberately a tiny closed set rather than an expression language: * `parseConfig` stays authoritative, and a rule that can only live there * cannot drift from a copy here. A provider whose constraint does not fit in * these three keys should express it in `parseConfig` alone. */ constraints?: { min?: number; max?: number; maxLength?: number; }; /** * What a BLANK value means for this field, when the field is optional. * * A client editing a stored provider can express three things about an * optional field — leave it, set it, remove it — and a blank input has to be * mapped onto one of them. Which one is right depends entirely on how the * provider's own parser is written, and nothing else in the descriptor says: * * - `"omit"` (the default) suits `z.string().min(1).optional()` and * `z.enum(...).optional()`, which accept an absent key and reject `""`. * - `"empty"` suits a key nested inside a REQUIRED object, where the parser * demands the key exist and decides for itself what an empty value means. * The built-in SMTP provider is the live example: `auth` is required and * its `user`/`pass` may be empty for a loopback sink, so omitting them * fails with "expected string, received undefined" for the one setup this * repository documents. * * Declared rather than guessed, for the same reason `secret` is: a client * cannot read `parseConfig`, and the two shapes are indistinguishable from * the outside. Ignored for a required field, which can never be blank. */ blankAs?: "omit" | "empty"; } /** What a provider can do, so a UI never offers what it cannot honour. */ interface EmailProviderCapabilities { /** Accepts file attachments. */ attachments?: boolean; /** Can be probed without sending a message (`testConnection`). */ connectionTest?: boolean; /** Honours a Reply-To address. */ replyTo?: boolean; /** * Only accepts a sender on a domain verified with the provider. * * Declared rather than inferred. A hosted API provider generally requires it * and a self-hosted relay does not, but nothing else in the descriptor * distinguishes them — using the presence of `docsUrl` as the signal reads as * a rule and is a coincidence, and it silently drops the warning for any * provider that documents itself elsewhere. * * The consequence of getting it wrong is quiet: a provider saves cleanly with * an unusable sender and fails at the first send. */ requiresVerifiedSender?: boolean; } /** * A registered mail provider. * * @typeParam TConfig - the shape `parseConfig` produces and `createAdapter` * consumes. Defaulted so a definition can be held without naming it. */ interface EmailProviderDefinition> { /** Stored in `email_providers.type`. Unique across built-ins and plugins. */ type: string; /** Shown in the provider picker. */ label: string; description?: string; /** Where to read about getting credentials. */ docsUrl?: string; /** * One line about which sender addresses this provider will accept. * * Shown beside the From address. Only for a provider whose rule cannot be * derived from `capabilities.requiresVerifiedSender` alone — Resend, for * instance, publishes a shared testing address that works before any domain * is verified, and a form that says only "use a verified domain" makes a * usable configuration look impossible. * * Prose in a wire format is a cost, and it is the same cost `help` and * `description` already pay: the alternative is provider-specific copy * hardcoded in a client, which is what a catalog exists to end. */ senderGuidance?: string; capabilities?: EmailProviderCapabilities; /** Field metadata, in the order a form should render it. */ configFields: ReadonlyArray; /** * Validate stored or submitted configuration. The authoritative boundary. * * A function rather than a schema object so no validation library becomes * part of the provider contract: a package may use Zod internally, but a Zod * major would otherwise break every third-party provider at once, and each * would have to resolve a version compatible with core's. * * Throws when the input cannot be used. `NextlyError.validation` gives the * caller field paths; any thrown error is caught and reported by the service. */ parseConfig: (input: unknown) => TConfig; /** Build the adapter that sends. */ createAdapter: (config: TConfig) => EmailProviderAdapter; /** * Cheap reachability probe that sends nothing. * * Only meaningful where the protocol has one — SMTP can open a session and * authenticate, a REST provider generally cannot check anything short of * sending. Declare `capabilities.connectionTest` alongside it, so a UI can * tell the difference between "this failed" and "this cannot be asked". */ testConnection?: (config: TConfig) => Promise<{ ok: boolean; detail?: string; }>; } /** * A registered definition with its config type erased. * * The registry holds providers whose `TConfig`s differ, and no single generic * argument describes them all: `parseConfig` and `createAdapter` are * contravariant in it, so `EmailProviderDefinition` rejects every * concrete definition. * * The erased form takes `unknown` and returns the parsed value as `unknown`. * The TYPE stays inside the closure that knows it, which is what removes the * need to cast one back; `createAdapterFrom` still parses AND builds, so an * adapter cannot be constructed from configuration that was never validated. * That safety property is unchanged. * * What changed is that the parsed VALUE now comes back out, because the caller * that persists a configuration has to persist the one the adapter will run * on. While it did not, the stored configuration and the configuration the * adapter received could differ, and nothing reconciled them: a parser doing * `raw.trim()` meant containment compared a padded credential against an * unpadded one, and a `z.coerce` meant the admin rendered a value its own * control could not hold. Both were patched at the symptom before the cause * was addressed here. */ interface RegisteredEmailProvider { type: string; label: string; description?: string; docsUrl?: string; senderGuidance?: string; capabilities?: EmailProviderCapabilities; configFields: ReadonlyArray; /** * Parse this configuration, or throw if it is unusable. * * Returns the PARSED value, which is what a caller must persist: what the * adapter runs on is this, not the input. `unknown` rather than the concrete * type, so the erasure holds — a caller can store it and hand it back, and * cannot read it as anything in particular. */ parseConfiguration: (input: unknown) => unknown; /** Validate and build in one step; the parsed value never escapes. */ createAdapterFrom: (input: unknown) => EmailProviderAdapter; /** Present only when the definition supplied a probe. */ testConnectionFrom?: (input: unknown) => Promise<{ ok: boolean; detail?: string; }>; /** Whether a probe exists, without exposing it. */ readonly hasConnectionTest: boolean; } /** * Register a provider, erasing its config type. * * This is the function a provider package calls. Its argument stays fully * typed, so an author gets checking on the shape they actually wrote, while * the registry receives something it can store beside every other provider. */ declare function defineEmailProvider(definition: EmailProviderDefinition): RegisteredEmailProvider; /** * The browser-safe half of a definition. * * Sent to the admin so it can render a form for a provider core was never * compiled against. Functions are dropped rather than serialized to `undefined` * by accident, and nothing here carries a stored value. */ interface EmailProviderDescriptor { type: string; label: string; description?: string; docsUrl?: string; senderGuidance?: string; capabilities: EmailProviderCapabilities; configFields: ReadonlyArray; } /** * @public A value that survives being sent to the browser as JSON. * * Spelled out rather than widened to `unknown` so the compiler refuses a * function or a `Date` at the point it is written, where the author can see * what they meant, instead of at the point it silently arrives as `null`. */ type JsonValue = string | number | boolean | null | readonly JsonValue[] | { readonly [key: string]: JsonValue; }; /** * @public A JSON object. Offered for plugin authors who want to state the * shape of their own config precisely. * * Deliberately NOT the type of `clientConfig` itself. An `interface` has no * implicit index signature in TypeScript, so an author's * `interface MyConfig { … }` would not satisfy this however plainly JSON it * is — the error would land on correct code and the fix would be "rewrite your * interface as a type alias", which teaches nothing about serialization. The * constraint is enforced where it can be enforced exactly, at the boundary the * value actually crosses. */ type JsonObject = { readonly [key: string]: JsonValue; }; /** * @public A reference to a plugin-provided admin React component, * resolved client-side through the string-path component registry. * * Format: `"/#"`, * e.g. `"@nextlyhq/plugin-form-builder/admin#FormBuilderView"`. * * A plain `string` until typed-component codegen narrows it. */ type ComponentPath = string; /** * @public Built-in admin header buttons that a plugin may hide. * The user/account dropdown is intentionally NOT controllable (logout must * stay reachable). */ type HeaderButtonId = "github" | "discord" | "docs" | "notifications"; /** * @public Header customization contributed by a plugin. * * `slot` adds a component to the header (supersedes the deprecated top-level * `headerSlot`). `hideDefaults` / `hide` remove built-in buttons; hiding is * subtractive and **union-merged** across enabled plugins (a button is hidden * if ANY enabled plugin hides it). */ interface PluginHeaderContributions { /** Component rendered in the header, before the notifications bell. */ slot?: ComponentPath; /** Hide all built-in header buttons (github, discord, docs, notifications). */ hideDefaults?: boolean; /** Hide specific built-in header buttons. */ hide?: HeaderButtonId[]; } /** * @public A sidebar navigation entry contributed by a plugin. * * Declarative and introspectable — delivered to the client via `/api/admin-meta`. * Exactly **one** level of `children` is supported. Visibility is controlled by * `requiredPermission` (client-gated via `useCan`); a `visible(ctx)` callback is * intentionally NOT supported because menus are serialized to the client. */ /** * @experimental Where a plugin's admin surfaces appear in the sidebar. * * A closed vocabulary rather than a free string, so a typo is a compile error * instead of a page that quietly appears nowhere. `"standalone"` gives the * plugin its own top-level entry, drawn with the icon it declares in * `contributes.admin.appearance`. * * Omitting it is the common case and is not the same as choosing a default: * an absent value defers to the plugin's own `placement`, so a plugin that has * already said where it lives does not repeat itself per page. */ type PluginNavSection = "dashboard" | "collections" | "singles" | "media" | "plugins" | "settings" | "standalone"; interface PluginMenuItem { /** Display label. */ label: string; /** Admin path to navigate to, e.g. `"/admin/collections/forms"`. */ to: string; /** Lucide icon name (resolved client-side). */ icon?: string; /** Sort order within the plugin's items; lower = higher. Default 100. */ order?: number; /** Hide the item unless the current user holds this permission (client-gated, D36). */ requiredPermission?: PermissionSlug; /** * @experimental Which sidebar section lists this item. Defers to the * plugin's own `placement` when omitted. */ section?: PluginNavSection; /** One nested level of sub-items. */ children?: PluginMenuItem[]; } /** * @public A plugin-contributed admin page, mounted under the * plugin's namespace (`/admin/plugins//`) and RBAC-gated. */ interface PluginAdminPage { /** Path relative to the plugin namespace (no leading slash), e.g. `"reports"`. */ path: string; /** Component rendered for this page. */ component: ComponentPath; /** Required permission to view the page (route-level RBAC, D36). */ requiredPermission?: PermissionSlug; /** * @experimental Which sidebar section is selected while this page is open. * Defers to the plugin's own `placement` when omitted. * * The page's URL is namespaced under the plugin, so without this the rail * could only ever say "Plugins" — a plugin that lives under Settings would * have its collections there and its pages elsewhere. */ section?: PluginNavSection; } /** * @experimental A plugin-contributed dashboard widget. * * RESERVED — the contract is published for forward-compatibility, but * widget rendering / the dashboard grid is **deferred** and is NOT * built. Declaring widgets has no effect yet. */ interface PluginAdminWidget { id: string; component: ComponentPath; size?: "full" | "half"; requiredPermission?: PermissionSlug; } /** * @public Per-collection admin view overrides + injection points, * keyed by the (resolved) collection slug. Each maps to the collection-level * `admin.components` resolution the admin already performs. */ interface PluginCollectionView { /** Replace the default List view. */ list?: ComponentPath; /** Replace the default Edit view. */ edit?: ComponentPath; /** Inject above the list table. */ beforeList?: ComponentPath; /** Inject below the list table. */ afterList?: ComponentPath; /** Inject above the edit form. */ beforeEdit?: ComponentPath; /** Inject below the edit form. */ afterEdit?: ComponentPath; } /** * @public Declarative admin-UI contributions. Introspectable * by the host without running the plugin. * * Consumed: `menu`, `pages` + `settings`, `views`. * `widgets` is RESERVED — deferred; not rendered. */ interface PluginAdminContributions { /** Sidebar navigation entries. */ menu?: PluginMenuItem[]; /** Custom admin pages, namespaced + RBAC-gated. */ pages?: PluginAdminPage[]; /** Plugin settings UI rendered at `/admin/plugins/`. */ settings?: { component: ComponentPath; }; /** * @experimental Dashboard widgets — now rendered by `PluginWidgetGrid` * on the admin dashboard, permission-gated. Graduates per D55. */ widgets?: PluginAdminWidget[]; /** Per-collection view overrides + injection points, keyed by slug. */ views?: Record; /** * Precompiled, `.nextly-admin`-scoped, token-driven CSS this plugin ships for * admin components whose utilities are not in the built-in safelist. A * package-relative reference (or several), e.g. "@acme/plugin/dist/admin.css". * * Declaring this does NOT load anything. The plugin's admin entry must * side-effect-import the file (`import "./dist/admin.css"`), which is what * makes the consumer's bundler load and dedupe it; this field is the * machine-readable statement of that fact, for tooling and for anyone reading * the manifest. The two can therefore disagree — declaring a file the entry * never imports renders unstyled with no error — so keep them in step. * * Omit when the plugin styles itself from SDK components plus safelisted * utilities. */ styles?: string | string[]; /** * @deprecated Use `header.slot`. A component rendered in the admin top bar / * header. The component self-gates on permission. Rendered inside the * plugin boundary. Still honored (folded into `header.slot`) for back-compat. */ headerSlot?: ComponentPath; /** * @experimental Header customization: add a component (`slot`) * and/or hide built-in buttons (`hideDefaults`/`hide`). The slot self-gates * on permission and renders inside the plugin boundary. */ header?: PluginHeaderContributions; /** * @experimental A component rendered in the schema-builder pages (collection + * single builders), above the field list. Receives `{ fields, setFields, * disabled, context: "collection" | "single" }` so it can add builder-time * controls (e.g. an editor-choice toggle) that mutate the field list — without * core knowing the plugin. Rendered inside the plugin boundary. */ schemaBuilderSlot?: ComponentPath; /** * @experimental A component rendered in the entry/single form header toolbar. * Receives `{ context: "collection" | "single"; controllerField?: string }` and * reads/writes form state via react-hook-form context (it renders inside the * form's provider). Lets a plugin add a form-level control (e.g. a Default / * Page Builder mode toggle) without core knowing the plugin. Rendered inside * the plugin boundary. */ entryFormToolbarSlot?: ComponentPath; /** * @experimental Configuration this plugin's own admin components need, * delivered to the browser through `/api/admin-meta`. * * A plugin's factory runs where the host builds its config — on the server, * at startup. Its admin components run in the browser, and nothing otherwise * connects the two: a module-level variable is set in the wrong process, and * the edit-view props are core's contract rather than the plugin's. Without * this a plugin can ship behaviour it cannot configure, which is how the page * builder's canvas came to enforce an empty allowlist while the rendered page * enforced the host's. * * ## This is PUBLIC * * `/api/admin-meta` requires no authentication — the login screen reads its * branding from it before anyone has signed in — so this is served to * ANONYMOUS callers, not merely to every admin. It is the wrong place for * API keys, tokens, internal hostnames, licence state, or anything whose * value depends on who is asking. Put those behind a route that can check * the caller. * * The test to apply: would you paste this into a public issue? If not, it * does not belong here. * * ## It must be JSON * * Functions, class instances, `Date`s and `Map`s do not survive the trip, and * a value that silently changes shape between the server and the client is * worse than one that is rejected — so the serializer refuses anything that * does not survive a round trip, naming the plugin, rather than emitting a * mangled copy. That is the same reason `PluginMenuItem` takes no * `visible(ctx)` callback. * * Typed as `object` on purpose, which is as loose as it can usefully be * while still refusing a primitive. TypeScript cannot say * "JSON-serializable" in a way an ordinary `interface` satisfies — * an interface has no implicit index signature, so even * `Record` rejects one — and a type that rejects correct * config teaches the author about index signatures instead of about * serialization. The exact check belongs at the boundary the value crosses, * where it can be exact. {@link JsonObject} is exported for authors who want * to state their own shape precisely. */ clientConfig?: object; } /** * @experimental Plugin auth contributions. Hooks, challenge * definitions, and auth-page UI are normal contributions; auth *strategies* are * app-opt-in and live in `defineConfig({ auth: { strategies } })`, not here. * Ships `@experimental` until a first-party plugin exercises it. */ interface PluginAuthContributions { /** Auth-flow hooks (modify / abort / challenge). */ hooks?: AuthHooks; /** Challenge definitions this plugin can resolve (e.g. TOTP). */ challenges?: ChallengeDefinition[]; /** Auth-page UI — provider buttons, challenge views, and form slots. */ ui?: { /** Buttons on the login screen that start a named strategy. */ providers?: Array<{ strategy: string; label: string; icon?: string; component?: ComponentPath; }>; /** Map of `challengeType -> component` for rendering a challenge step. */ challengeViews?: Record; /** Injection points around the login form. */ slots?: { beforeForm?: ComponentPath; afterForm?: ComponentPath; branding?: ComponentPath; }; }; } /** * @public HTTP methods a plugin route may declare. */ type RouteMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; /** * @public Per-request context handed to a plugin route handler. * * It is the plugin's boot-built {@link PluginContext} (services/db/logger/events/ * hooks/filters/actions/self/config) plus the per-request `user` and path `params`. */ interface PluginRouteContext extends PluginContext { /** * The authenticated user, or `null` for a `public` route reached without a * session. Pass it to secure-by-default services as `{ as: 'user', user }`. */ user: AuthUser | null; /** Path parameters captured from `:param` segments in the route's path. */ params: Record; } /** * @public A plugin route handler. Receives the raw web `Request` (body/ * query/headers) plus the per-request {@link PluginRouteContext}. */ type PluginRouteHandler = (req: Request, ctx: PluginRouteContext) => Response | Promise; /** * @public Typed, ordered route-level middleware (onion model, D27). Call * `next()` to continue the chain, or return a `Response` to short-circuit. */ type Middleware = (req: Request, ctx: PluginRouteContext, next: () => Promise) => Promise; /** * @public A single HTTP route contributed by a plugin. Mounted at * `/api/plugins/` under the existing catch-all and secure by * default (auth + RBAC) unless `public: true`. */ interface PluginRoute { method: RouteMethod; /** * Path within the plugin namespace; MUST start with `"/"`. Supports `:param` * segments (e.g. `"/items/:id"`). Final URL: `/api/plugins/`. */ path: string; handler: PluginRouteHandler; /** Secure-by-default: the permission slug required to call this route. */ requiredPermission?: PermissionSlug; /** Opt out of auth — the route is publicly callable. */ public?: boolean; /** Ordered, typed route-level middleware chain. */ middleware?: Middleware[]; } /** * @public A plugin-declared custom permission. CRUD permissions are * auto-seeded per collection/single slug separately — declare only NON-CRUD * custom permissions here (e.g. `{ action: 'export', resource: 'submissions' }`). */ interface PluginPermission { action: string; resource: string; label?: string; description?: string; /** * A heading to file this permission under, within the plugin's own section * of the admin's permission matrix. Defaults to `"General"`. * * The section itself is not yours to choose — it is the package that * declared the permission, which the host knows and cannot be wrong about. * This is the level below that: a plugin with two permissions needs no * groups, and one with forty needs them badly, and only the plugin knows * which of its own verbs belong together. */ group?: string; /** * Mark a permission that hands out access, destroys data irreversibly, or * reaches outside the site. The admin warns before granting it. * * A boolean, not a message: the warning's wording belongs to the host, so * that it reads the same everywhere and stays recognisable. A permission * that explains its own danger in its own voice is one people stop reading. */ danger?: boolean; } /** * @experimental A plugin-declared role bundle — a named set of permissions * an admin can grant as a unit. Seeded on boot (idempotent by slug), tagged * `isSystem: false`, and **never auto-assigned** to users (D36 — define, don't * grant). Reference permissions by their `${action}-${resource}` slug. */ interface PluginRole { /** Unique role slug (e.g. `'content-reviewer'`). `'super-admin'` is reserved. */ slug: string; /** Human-readable name (e.g. `'Content Reviewer'`). */ name: string; description?: string; /** Permission slugs this role bundles, e.g. `['read-posts', 'approve-posts']`. */ permissionSlugs: string[]; /** Authority level (higher = more senior); default 0. */ level?: number; } /** * @experimental A reserved scheduled-task declaration. **Not executed yet** * — see `contributes.schedules`. The shape is forward-designed so it stays stable * once a durable-jobs backend lands. */ interface ScheduledTask { /** Unique, namespaced task name, e.g. `'seo.regenerate-sitemap'`. */ name: string; /** Cron expression or interval in milliseconds (reserved; not yet honored). */ schedule: string | number; /** Task handler (reserved — the runtime does not invoke it yet). */ handler?: (ctx: PluginContext) => Promise | void; description?: string; } /** * @experimental A plugin-contributed email provider. * * The value produced by `defineEmailProvider(...)`, not the definition literal. * That indirection is what keeps a plugin's own config type usable: a * definition typed to its own shape is NOT assignable to one typed to * `Record`, because `createAdapter` accepts the narrower type * and function parameters are checked contravariantly. Authors would have had * to widen every adapter to `Record` and re-narrow inside, * which is exactly the guard the definition contract exists to remove. * * `defineEmailProvider` erases the type at the boundary instead, so the author * keeps full checking on the shape they wrote and core receives something it * can store beside every other provider. * * @example * ```ts * import { defineEmailProvider } from "@nextlyhq/plugin-sdk"; * * interface PostmarkConfig { serverToken: string } * * contributes: { * emailProviders: [ * defineEmailProvider({ * type: "postmark", * label: "Postmark", * configFields: [ * { name: "serverToken", label: "Server Token", kind: "password", required: true, secret: true }, * ], * parseConfig: (input) => postmarkSchema.parse(input), * // `config` is PostmarkConfig here, not a widened record. * createAdapter: (config) => createPostmarkAdapter(config), * }), * ], * } * ``` */ type PluginEmailProvider = RegisteredEmailProvider; /** * @experimental A plugin-contributed email template, seeded into the * `email_templates` table on boot (idempotent by slug; never clobbers admin * edits). Resolvable by slug via `sendWithTemplate` and the direct API. */ interface PluginEmailTemplate { slug: string; name: string; /** Subject line; supports `{{variable}}` interpolation. */ subject: string; /** HTML body; supports `{{variable}}` interpolation. */ htmlContent: string; plainTextContent?: string; variables?: Array<{ name: string; description?: string; required?: boolean; }>; /** Wrap with the shared layout; default true. */ useLayout?: boolean; } interface PluginFieldType { /** Field type id used as `field.type` (e.g. `"rating"`). Must not collide with a built-in. */ type: string; /** The existing storage primitive this type persists as. */ storage: FieldStoragePrimitive; /** Admin field-editor component path, resolved via the component registry. */ component: ComponentPath; /** * Human label shown in field-type pickers. Defaults to a title-cased `type` * (e.g. `"rating"` → `"Rating"`). */ label?: string; /** One-line hint shown under the label in pickers. */ description?: string; /** * Lucide icon name shown on the picker card, resolved by the admin's icon * set. Falls back to a generic icon when the name does not resolve. */ icon?: string; /** Picker grouping. Defaults to `"Advanced"`. */ category?: FieldTypeCategory; /** * Which admin surfaces may offer this type in their field pickers. Omitted * means the entry/single editing surface only — a type never auto-appears * on a surface its author did not opt into. Instances of a type that later * stops being offered still render (read-only degradation), they are never * dropped. */ surfaces?: readonly FieldSurface[]; /** * Layout hint for the entry/single form. `"takeover"`: when a visible field of * this type is present, the form body shows only that field plus the field that * controls its `admin.condition` (e.g. an editor-mode switch), hiding the rest. * Generic — any plugin field type may opt in. */ layout?: "takeover"; /** * Server-side validation for values stored in this field type. * * Without this a custom type is only ever checked as its `storage` * primitive — for `json` that means "is it JSON" and nothing more — so a * type could state no rule about what it accepts. Declared here rather than * per field because a type's rules are properties of the type: every * instance gets them, instead of each schema author remembering to repeat a * `validate` function. * * Runs after the built-in rules for the storage primitive and BEFORE the * field's own `validate`, so a schema author's rule composes on top of this * one rather than replacing it. An absent or empty value never reaches here * — that is what `required` is for — and neither does one the storage * primitive already refused, so a validator never has to re-check that it * was handed the shape its type stores. * * Return `true` to accept. Return a string for a single problem, or an array * when one value can be wrong in several places at once (a structured * document, a list of rows); each issue may carry its own `path` so the * writer is told where. Anything else is treated as a refusal, as is * throwing, so a validator that forgets to return fails loudly rather than * silently accepting everything. * * Runs on the entry, single, and component write paths. A type offered on * the `users`, `forms`, or `blocks` surface does NOT run it yet: those * surfaces validate through their own paths, which do not consult this * registry. */ validate?: (value: unknown, args: PluginFieldValidateArgs) => PluginFieldValidationResult | Promise; /** * Checks on the field's own DECLARATION, run when a schema is registered * rather than when a value is written. * * `validate` answers "is this value allowed in this field". This answers "is * this field declared coherently at all" — a policy option that is not the * shape the type reads, or one whose settings contradict each other so no * value could ever satisfy them. Those are defects in the schema, and a * schema defect that surfaces per write is reported to the wrong person: the * writer cannot fix it, and it fails every write until whoever declared the * field notices. * * Runs on every path a declaration reaches storage by: boot, `db:sync` and * its watcher, a Schema Builder write, `nextly build`, `migrate:create`, and * an HMR reload. Each sits after the field-type registry is populated, * because the config bundle is evaluated before `contributes.fieldTypes` is * registered — so the `define*` calls, where a code-first config is otherwise * validated, reject a custom type as unknown before any option check of it * could run. * * Checks the declaration as WRITTEN. On the Builder path that means the * submitted payload rather than the parsed copy, because the manifest schema * drops keys it does not declare while the write persists the original — so * the options a type reads are present in what is stored and absent from what * was parsed. * * Runs for fields on collections, singles and components, including nested * ones. A type offered only on the `users`, `forms`, or `blocks` surface does * NOT get its declarations checked: those surfaces have their own config * validators, which do not consult this registry. * * Synchronous on purpose: a declaration is checked against itself, and a * config-time rule that needed I/O would make startup depend on something * that can be down. * * Return `true` to accept, a string for one problem, or an array to point at * individual options — a path is appended to the field's own, so `"allow"` * reports against `fields[2].allow`. A `code` is not carried: these are * reported through error-code unions that are closed and public, so the * canonical member is used and the message carries the detail. Throwing is * treated as a refusal. * * Options are read from the field itself and from its `pluginOptions` * container, merged into one flat view with the container winning, so where * an option was stored is not something this has to know. Directly on the * field is legal only while the name differs from every key the field schema * declares (`options`, `fields`, `admin`, `label`, and the rest of the * built-in field surface): the manifest applies that shape to every field * regardless of type, so a colliding name is judged against the core meaning * and refused before this runs. The container is where such a name can mean * something else, because core never looks inside it — except for `type` and * `name`, which the instance restates as its own identity and so cannot carry * an option; a manifest write using either inside the container is refused. * * Paths here are RELATIVE, where `validate`'s are absolute. The difference is * deliberate: a value validator may address a position deep inside a stored * document and is told where the field sits so it can build that, while this * one only ever names an option it already knows by name and has no way to * learn its own index. */ validateOptions?: (field: PluginFieldInstance) => PluginFieldValidationResult; /** * What `nextly build` emits for a field of this type. * * Without it a custom type is generated as its storage primitive's default — * `string` for the TypeScript types, an unconstrained value for the Zod * schemas — because the generators know the built-in types and nothing else. * That is the difference between a type an app can consume and one it has to * cast at every use, and it is the reason a structured type is worth * contributing rather than storing as opaque JSON. * * Both callbacks receive the field as DECLARED, so a type whose options * narrow what it stores can narrow what it generates: a field restricted to * two kinds can emit a union of those two rather than the whole set. * * The strings are written verbatim into the generated file, which is source * the app compiles. A malformed expression breaks that app's build rather * than anything here, so a type is expected to emit something it has checked; * the generators do not parse it. Keep the output deterministic — the file is * committed, and a value that varies between runs shows up as a spurious * diff. */ codegen?: PluginFieldCodegen; /** * What a field of this type holds when nothing has been written to it. * * Two paths need it and must agree: backfilling a NOT NULL column added to a * table that already has rows, and seeding a required field on a record * created without one — a single auto-created on first read. Core derives * both from the storage primitive (`{}` for `json`, `0` for `number`), which * is right for a type that stores a bag and wrong for one that stores a * structured document: `{}` satisfies the column and then fails every read * that expects the structure. * * Returns the VALUE, never SQL and never a pre-serialized string. A * `boolean`-backed type returning `"false"` would seed a truthy string into * a boolean column, so the type states what it holds and each caller renders * it: the DDL path quotes and escapes it for the dialect being generated, * the runtime path serializes it only when the column stores JSON. Returning * nothing keeps the primitive's default. * * The field is passed as declared, so the value can honour the options on it * — a document field restricted to one kind can seed a document of that kind * rather than a generic one. */ emptyValue?: (field: PluginFieldInstance) => unknown; } /** A type-only import a generated file needs for one field type's expressions. */ interface PluginFieldCodegenImport { /** Names to import, e.g. `["BlockDocument"]`. */ names: readonly string[]; /** * Module to import them from. * * Name a package the app already depends on. The generated file sits in the * app, not in the plugin, so it resolves against the app's dependency tree — * an import of a plugin's own transitive dependency may not resolve there. */ from: string; } /** How a plugin field type is rendered by the code generators. */ interface PluginFieldCodegen { /** * The TypeScript type of a stored value, e.g. `"BlockDocument"` or * `'"draft" | "live"'`. Omitted falls back to the storage primitive's type. */ tsType?: (field: PluginFieldInstance) => string; /** * Type-only imports `tsType` relies on. * * Declared per expression rather than once for both, because the two are * emitted into different files. A name listed here appears only in the * TypeScript output, so an app compiled with `noUnusedLocals` does not fail on * an import the other file never uses. */ tsImports?: readonly PluginFieldCodegenImport[]; /** * A Zod expression validating a stored value, e.g. * `"z.object({ kind: z.enum([\"page\"]) })"`. Omitted falls back to the * storage primitive's schema. */ zodSchema?: (field: PluginFieldInstance) => string; /** Type-only imports `zodSchema` relies on, e.g. for `z.custom()`. */ zodImports?: readonly PluginFieldCodegenImport[]; } /** What a plugin field type's `validate` is given. */ interface PluginFieldValidateArgs { /** * The write payload, for rules that span fields — the whole object on * create, and on update the patch rather than the merged stored entry, so a * field the writer did not send is absent here even when it has a stored * value. Always the top-level payload: a field nested in a repeater row or * group still sees the write, not the row. */ data: Record; /** * Request context; carries `user` when the write is authenticated. The * parent write's request, forwarded unchanged, including to a field nested * inside a component instance — which is validated by its own pass, in its * own service, so the context has to be carried there rather than being in * scope already. * * Empty for a write with no request behind it: an internal write, a seed, * or an unauthenticated one. */ req: Record; /** * The field instance, so a validator can read the options its own type * declares (a `rating`'s `max`, a `blocks`' `allow`). * * A detached copy: records, arrays, dates, sets and maps are all rebuilt, so * editing them changes nothing the next write sees. The exceptions are what * cannot be copied without becoming something else — a function, and an * instance of a class core has no constructor for — which stay shared. Treat * the whole thing as read-only. */ field: PluginFieldInstance; /** * Where this field sits in the write (`"stars"`, `"rows[2].stars"`). * Returned issue paths are used as given, so prefix with this to point * inside a value; a validator has no other way to know its own location. */ path: string; /** `create` requires absent values; `update` treats them as untouched. */ mode: "create" | "update"; } /** * A field as the validation pass sees it. * * Deliberately loose: one pass runs over both code-first field configs and * stored runtime definitions, whose option shapes differ, so a validator reads * its own options rather than being handed a narrowed type that would be a * lie for one of the two. */ interface PluginFieldInstance { name?: string; type: string; label?: unknown; required?: boolean; readonly [option: string]: unknown; } /** One problem with a stored value. */ interface PluginFieldIssue { /** * Where the problem is, used exactly as given. Defaults to the field's own * path; supply one to point inside a structured value, building it from * `args.path` so it stays right for a nested instance * (`` `${args.path}.nodes[2].props.level` ``). */ path?: string; /** Stable machine code for clients to branch on. Defaults to `"CUSTOM"`. */ code?: string; /** A complete sentence. A trailing period is added when missing. */ message: string; } type PluginFieldValidationResult = true | string | PluginFieldIssue[]; /** * @public A permission identifier — the `${action}-${resource}` slug * (e.g. `'export-submissions'`). * * When generated types exist (run `nextly generate:types`), this narrows to the * union of seeded permission slugs (CRUD per collection/single + custom plugin/ * app permissions, D36/D47). Without generated types — or when no permissions * are present — it falls back to `string` (same convention as `CollectionSlug`). */ type PermissionSlug = GeneratedTypes extends { permissions: infer P; } ? keyof P & string : string; /** * Declarative, introspectable plugin contributions. The host can read these * WITHOUT running the plugin. * * @public Each key is *consumed* by a phase: collections/singles/components/ * extend → P2 (merge pipeline); permissions → P3; events → P1; routes → P4; * admin → P5 (menu/pages/settings/views; widgets reserved for M8). */ interface PluginContributions { /** @public New plugin-owned collections. Merged by the schema pipeline. */ collections?: CollectionConfig[]; /** @public New plugin-owned singles. */ singles?: SingleConfig[]; /** @public Plugin-owned field groups. */ fieldGroups?: FieldGroupConfig[]; /** * @public Add fields to existing entities by slug. * * Authored fields, not canonical ones: `collections`, `singles` and * `fieldGroups` each arrive through a `define*` call that has already * narrowed them, while these are written inline with nothing to narrow them, * so a plugin's own contributed type has to be nameable here. */ extend?: Array<{ target: string | string[]; fields: AuthorableFieldConfig[]; }>; /** @public Custom permissions; CRUD is auto-seeded separately. */ permissions?: PluginPermission[]; /** @experimental Role bundles — named sets of permissions, seeded on boot. */ roles?: PluginRole[]; /** * @experimental Custom services registered into DI. Each entry is a * factory `(ctx) => instance`; the service is exposed lazily (instantiated on * first access) at `ctx.services.plugins..` and * `nextly.plugins..`. Other plugins consume it via * their own `ctx.services.plugins..`. */ services?: Record unknown>; /** * @experimental Static data this plugin publishes for OTHER plugins, keyed by * the consuming plugin's name. Core stores it and never reads inside it. * * The counterpart to `services`, and the reason it exists: a service is a * factory, so its contents are knowable only once a plugin's `init` has run. * Everything else here is plain data, which is what lets `nextly generate:types` * build generated artifacts by reading the config alone — it loads no plugin * runtime and opens no database. A capability offered only through `services` * is therefore invisible to generation, and cannot appear in an import map, a * manifest, or generated types. * * Declaring the data here and registering FROM it at boot keeps one source for * both, so tooling and runtime cannot disagree about what a plugin provides. * * Keyed by consumer name rather than by capability so core stays out of it: * a page builder reads `declarations["@nextlyhq/plugin-page-builder"]` and * decides what its own shape means, exactly as it already does for the * service it hands back. * * @example * ```ts * contributes: { * declarations: { * "@nextlyhq/plugin-page-builder": { blocks: [pricingTable] }, * }, * } * ``` */ declarations?: Record; /** * @experimental Scheduled tasks — **RESERVED, NOT EXECUTED** in this * release. The shape is published so authors aren't surprised by its absence, * but the runtime does not run these yet (a real scheduler needs durable jobs, * D51, because the typical Next.js/serverless deploy has no long-lived * process). Until then: trigger work via an external cron service hitting a * route handler, or react to events (e.g. for cache * invalidation). See `docs/plugins`. */ schedules?: ScheduledTask[]; /** @experimental Custom email providers, registered into the provider registry. */ emailProviders?: PluginEmailProvider[]; /** @experimental Email templates, seeded idempotently into the DB on boot. */ emailTemplates?: PluginEmailTemplate[]; /** @experimental Custom field types — registry seam mapping to a storage primitive + admin component. */ fieldTypes?: PluginFieldType[]; /** @experimental Custom event names this plugin may emit. No first-party plugin declares custom events yet. */ events?: Array<{ name: string; }>; /** @public HTTP routes, namespaced under /api/plugins/. */ routes?: PluginRoute[]; /** * @public Admin UI contributions: menu, pages + * settings, per-collection view overrides. `widgets` is * RESERVED — deferred; not rendered and stays `@experimental`. */ admin?: PluginAdminContributions; /** * @experimental Auth extensibility: auth-flow hooks, challenge * definitions, and auth-page UI. Strategies are app-opt-in (defineConfig * `auth.strategies`), not here. */ auth?: PluginAuthContributions; } /** * Email Template Service * * CRUD operations for managing email templates stored in the * `email_templates` table. Supports template variable interpolation, * built-in template bootstrapping, and layout composition. * * A layout is a first-class row with `kind = 'layout'` whose * `htmlContent` holds a `{{content}}` placeholder where a template * body is injected at send time. * * @module services/email/email-template-service * @since 1.0.0 */ /** * Input for creating a new email template. * Extends EmailTemplateInsert (all required + optional fields). */ type CreateEmailTemplateInput = EmailTemplateInsert; /** * Input for updating an existing email template. * All fields are optional — only provided fields are updated. * Note: `slug` cannot be changed after creation. */ interface UpdateEmailTemplateInput { name?: string; subject?: string; htmlContent?: string; plainTextContent?: string | null; preheader?: string | null; layoutId?: string | null; fromOverride?: string | null; replyTo?: string | null; variables?: EmailTemplateVariable[] | null; useLayout?: boolean; isActive?: boolean; providerId?: string | null; attachments?: EmailAttachmentInput[] | null; } declare class EmailTemplateService extends BaseService { private emailTemplates; constructor(adapter: DrizzleAdapter, logger: Logger); /** * Record a template mutation without letting the trail decide the request. * * The write has already committed by the time this runs, so a failure to * record must not be reported to the caller as a failed mutation — that would * tell them the opposite of the truth. It must not be silent either: a trail * that quietly stops being written is indistinguishable from a system nobody * is changing, so the failure becomes a log line here. */ private recordActivity; /** * A layout row must contain exactly one `{{content}}` placeholder: zero * appends the body after the wrapper, and more than one drops the content * after the second marker when the layout is applied. Reject malformed * layouts at write time so rendering can rely on the invariant. */ private assertLayoutMarker; /** * Create a new email template. * * @throws NextlyError BUSINESS_RULE_VIOLATION if slug is reserved * @throws NextlyError DUPLICATE if slug already exists */ createTemplate(data: CreateEmailTemplateInput, actor?: RequestActor | null): Promise; /** * Get a single email template by ID. * * @throws NextlyError NOT_FOUND if template doesn't exist */ getTemplate(id: string): Promise; /** * Get a single email template by slug. * * Returns `null` if no template matches the slug. */ getTemplateBySlug(slug: string): Promise; /** * List email templates, ordered by creation date (newest first). * * Returns every row including layouts (`kind = 'layout'`); callers * that want only message bodies filter by `kind === 'template'`. */ listTemplates(): Promise; /** * Update an existing email template. * * Template `slug` cannot be changed after creation. * * @throws NextlyError NOT_FOUND if template doesn't exist */ updateTemplate(id: string, data: UpdateEmailTemplateInput, actor?: RequestActor | null): Promise; /** * Delete an email template. * * The default layout is undeletable (it is the fallback wrapper). * Custom layouts may be deleted — templates referencing them fall * back to the default via the `layoutId` set-null FK. Idempotent — * returns successfully if the template doesn't exist. * * @throws NextlyError BUSINESS_RULE_VIOLATION if deleting the default layout */ deleteTemplate(id: string, actor?: RequestActor | null): Promise; /** * Preview a template with sample data. * * Replaces `{{variable}}` placeholders with values from `sampleData`. * Supports dot-notation nested variables (`{{user.name}}`), HTML-escapes * values by default to prevent XSS, and wraps with shared layout * (header/footer) when `useLayout` is enabled. * * @throws NextlyError NOT_FOUND if template doesn't exist */ previewTemplate(id: string, sampleData: Record): Promise<{ subject: string; html: string; }>; /** * Inject an already-rendered body into a layout wrapper at its * `{{content}}` placeholder. The layout's own `{{variable}}` * placeholders (e.g. `{{year}}`, `{{appName}}`) are interpolated; * the body is spliced in verbatim (never re-escaped). */ renderWithLayout(layout: EmailTemplateRecord, body: string, variables: Record): string; /** * Ensure built-in templates exist in the database. * * First folds any legacy `_email-header` / `_email-footer` rows into * the unified default layout, then auto-creates the default layout, * welcome, password-reset, and email-verification templates if they * don't already exist. Idempotent — skips templates that already exist. */ ensureBuiltInTemplates(): Promise; /** * Seed plugin-contributed email templates (C2/D65). Idempotent by slug — a * template whose slug already exists is skipped, so an admin's edits to it (or * a built-in) are never clobbered. */ ensurePluginTemplates(templates: PluginEmailTemplate[]): Promise; /** * List all layout rows (`kind = 'layout'`), newest first. */ listLayouts(): Promise; /** * Get the default layout row, or null if none exists yet. * * The default layout is uniquely identified by BOTH the `default-layout` slug * and `kind = 'layout'`. Matching the slug alone would let a regular template * named `default-layout` masquerade as the (undeletable) wrapper, and matching * any `kind = 'layout'` row would resolve an arbitrary custom layout as the * default and cause legacy migration to be skipped. */ getDefaultLayout(): Promise; /** * Resolve the layout that wraps a given template: its explicit * `layoutId` when set and valid, otherwise the default layout. * Returns null when no layout exists at all. */ getLayoutFor(template: EmailTemplateRecord): Promise; /** * Fold legacy `_email-header` / `_email-footer` rows into a single * default layout row (`header + {{content}} + footer`), preserving * any operator customisations, then delete the legacy rows. * Idempotent — a no-op once a layout row exists. */ private migrateLegacyLayout; } /** * Email Service * * Central orchestration layer for email sending. Resolves providers * (DB default > code-first config), resolves templates (DB > code-first * overrides), handles variable interpolation, layout composition, * and delegates to the appropriate provider adapter (SMTP, Resend, * SendLayer). * * @module services/email/email-service * @since 1.0.0 */ /** * Dependencies needed to resolve attachments from the media library. * Injected into `EmailService` so the service doesn't need to know * which concrete `MediaService` / storage adapter is in use. */ interface EmailAttachmentSource { findMedia: (mediaId: string) => Promise; readBytes: (storagePath: string) => Promise; } declare class EmailService extends BaseService { private readonly providerService; private readonly templateService; private readonly emailConfig?; private readonly attachmentSource?; /** * Where sends are recorded. * * Optional so an install that predates the delivery table -- or a test * that does not care -- still sends. A missing recorder means no record, * never a failed send: the log exists to observe delivery, and it must not * become a thing that can prevent it. */ private readonly deliveries?; constructor(adapter: DrizzleAdapter, logger: Logger, providerService: EmailProviderService, templateService: EmailTemplateService, emailConfig?: EmailConfig | undefined, attachmentSource?: EmailAttachmentSource | undefined, /** * Where sends are recorded. * * Optional so an install that predates the delivery table -- or a test * that does not care -- still sends. A missing recorder means no record, * never a failed send: the log exists to observe delivery, and it must not * become a thing that can prevent it. */ deliveries?: EmailDeliveryService | undefined); /** * Resolve caller-provided attachments into bytes-ready form. * Returns `undefined` when no attachments supplied. Throws * `NextlyError` (validation for caller-fixable failures, internal for * storage I/O) on any failure — the caller (or `send()`) lets that * propagate. */ private resolveAttachmentsOrNone; /** * Send an email using a named template. * * Resolution order for templates: * 1. DB template (by slug) — interpolates variables, composes with layout * 2. Code-first template override from `defineConfig({ email: { templates } })` * 3. Error if neither exists * * @param templateSlug - Template slug (e.g., "password-reset", "welcome") * @param to - Recipient email address * @param variables - Key-value pairs for `{{variable}}` placeholder replacement * @param options - Optional provider/address overrides. Per-send `from` and * `replyTo` take precedence over the template's own overrides: the caller * knows the concrete send context (e.g. a form rule's sender), while the * template override is a static default. * @returns Send result with success status and optional message ID */ sendWithTemplate(templateSlug: string, to: string, variables: Record, options?: { providerId?: string; from?: string; replyTo?: string; cc?: string[]; bcc?: string[]; attachments?: EmailAttachmentInput[]; }): Promise<{ success: boolean; messageId?: string; }>; /** * Send a raw email (no template). * * Provider resolution order: * 1. Specific provider by ID (if `providerId` is provided) * 2. DB default provider * 3. Code-first provider from `defineConfig({ email: { providerConfig } })` * 4. Error if no provider configured * * @param options - Email sending options * @returns Send result with success status and optional message ID */ send(options: { to: string; subject: string; html: string; plainText?: string; /** Override the resolved provider From (e.g. a per-template From). */ from?: string; /** Reply-To header. Omitted when not set. */ replyTo?: string; providerId?: string; cc?: string[]; bcc?: string[]; attachments?: EmailAttachmentInput[]; /** * Which template produced this message, for the delivery log. * * The SLUG, never the rendered subject: a slug says which kind of message * this was and cannot carry a name, while a rendered subject is the field * most likely to interpolate one. */ templateSlug?: string; }): Promise<{ success: boolean; messageId?: string; }>; /** * Send a password reset email. * * Uses the `password-reset` template slug. Constructs the reset link * from the base URL + the configured reset password path. * * Path resolution (highest priority first): * 1. `options.path` (per-request override) * 2. `emailConfig.resetPasswordPath` (global config) * 3. `'/admin/reset-password'` (default) * * Returns the send result rather than `void`. `send()` converts a provider * throw into `{ success: false }` instead of propagating it, so a caller that * only awaits this cannot tell a failed delivery from a completed one — and * for an auth flow that difference decides what the user is told. Callers * that genuinely do not care may still ignore the value. */ sendPasswordResetEmail(to: string, user: { name: string | null; email: string; }, token: string, options?: { path?: string; }): Promise<{ success: boolean; messageId?: string; }>; /** * Send an email verification email. * * Uses the `email-verification` template slug. Constructs the verify link * from the base URL + the configured verify email path. * * Path resolution (highest priority first): * 1. `options.path` (per-request override) * 2. `emailConfig.verifyEmailPath` (global config) * 3. `'/admin/verify-email'` (default) * * Returns the send result rather than `void`. `send()` converts a provider * throw into `{ success: false }` instead of propagating it, so a caller that * only awaits this cannot tell a failed delivery from a completed one — and * for an auth flow that difference decides what the user is told. Callers * that genuinely do not care may still ignore the value. */ sendEmailVerificationEmail(to: string, user: { name: string | null; email: string; }, token: string, options?: { path?: string; }): Promise<{ success: boolean; messageId?: string; }>; /** * Send a welcome email. * * Uses the `welcome` template slug. When `verifyLink` is provided the * template includes a "Verify Email" button so the user can confirm * their address before logging in. * * Returns the send result rather than `void`. `send()` converts a provider * throw into `{ success: false }` instead of propagating it, so a caller that * only awaits this cannot tell a failed delivery from a completed one — and * for an auth flow that difference decides what the user is told. Callers * that genuinely do not care may still ignore the value. */ sendWelcomeEmail(to: string, user: { name: string | null; email: string; }, options?: { verifyLink?: string; }): Promise<{ success: boolean; messageId?: string; }>; /** * Whether this instance can actually send mail. * * Asking is otherwise only possible by trying: `resolveProvider` throws when * nothing is configured, so a caller that merely wants to know had to catch * the failure — and a caught failure is one nobody sees. Creating a user * whose only way in arrives by email needs to know before the user exists, * not after. */ isConfigured(): Promise; /** * Whether a specific template could be sent right now. * * A template may name its own provider, and `sendWithTemplate` prefers it * over the default — so "is anything configured" is the wrong question for a * caller about to send one particular template. An install whose only * provider is the one that template names would answer no to `isConfigured` * and still send perfectly well. * * Resolves the provider by the same precedence as the send itself, so the * answer matches what would happen. A template that cannot be looked up, or * is inactive, falls through to the default — again as the send does. */ canSendTemplate(templateSlug: string): Promise; /** * Resolve the provider adapter and "from" address. * * Priority: * 1. Specific DB provider (by ID) * 2. DB default provider * 3. Code-first config from `defineConfig({ email })` * 4. Error */ private resolveProvider; /** * Compose the final HTML by injecting the interpolated template body * into its resolved layout at the `{{content}}` placeholder. The * layout's own `{{year}}` / `{{appName}}` placeholders are filled; * the body is spliced in verbatim. Returns the body unchanged when * no layout exists. */ private composeWithLayout; /** * Create a provider adapter from a DB provider record (decrypted). */ private createAdapterFromRecord; /** * Create a provider adapter from code-first config (`defineConfig()`). */ private createAdapterFromConfig; /** * Get the base URL for email links. Delegates to the shared `getBaseUrl` * helper so email templates and absolutized media URLs resolve through * the same priority chain (emailConfig.baseUrl > NEXT_PUBLIC_APP_URL > * localhost). */ private getBaseUrl; /** * Get the application name for email templates. */ private getAppName; /** * Format a "from" address: `"Name "` or just `"email"`. */ private formatFromAddress; } interface RegisterUserData { email: string; password: string; name?: string; } /** * Result of {@link AuthService.generatePasswordResetToken}. * `token` is included only in dev-fallback paths (no email service or send * failed) per the existing security contract; in normal operation the token * is delivered by email and the response is just `{}`. */ interface ResetPasswordTokenResult { token?: string; } /** Result of {@link AuthService.resetPasswordWithToken} on success. */ interface ConsumeResetTokenResult { email: string; } /** Result of {@link AuthService.generateInviteToken}. */ interface InviteTokenResult { /** The raw, single-use token. Returned once and never stored. */ token: string; /** When the link stops working. */ expiresAt: Date; } /** Result of {@link AuthService.acceptInvite} on success. */ interface AcceptInviteResult { userId: string; } /** * Authentication Service * * Handles user authentication, password management, and email verification. * * **Token Security**: * - Tokens are generated using 32 bytes (256 bits) of cryptographically secure random data * - Raw tokens are returned to users as 64-character hex strings * - Tokens are hashed using SHA-256 before storage in the database * - This prevents token exposure even if the database is compromised * * **Token Cleanup**: * - Call `cleanupExpiredTokens()` periodically to remove expired tokens * - Safe to run frequently as it only deletes expired tokens * - Prevents token table bloat and maintains database performance * * @example * ```typescript * // Periodic cleanup (run in scheduled job) * await authService.cleanupExpiredTokens(); * ``` */ declare class AuthService extends BaseService { private readonly TOKEN_EXPIRY_HOURS; readonly emailService?: EmailService; constructor(adapter: DrizzleAdapter, logger: Logger, emailService?: EmailService); /** * What to return when a token could not be delivered by email. * * Outside production the raw token comes back, which is what makes a local * install usable before any provider is configured. In production it does * not: a reset or verification token is a credential, and OWASP's guidance * is that it may only travel by the side channel it was minted for. Handing * it to whoever called the endpoint turns a delivery outage into account * takeover for every account an attacker cares to name. * * The gate is what makes detecting more failures safe. Recognising an * unsuccessful send — rather than only a thrown one — necessarily routes * more situations here, and without this that would have widened where a * live token is handed back. */ private undeliveredTokenFallback; /** * Register a new user with email and password. * * @returns The newly created user (with `passwordHash` redacted). * @throws NextlyError(VALIDATION_ERROR) on weak passwords / invalid input. * @throws NextlyError on DB errors (e.g. a duplicate email surfaces as * `DUPLICATE` via fromDatabaseError; see PR 5 note below). */ registerUser(userData: RegisterUserData): Promise; /** * Verify user credentials for login. * * @returns The authenticated user on success (passwordHash redacted). * @throws NextlyError(AUTH_INVALID_CREDENTIALS) for unknown email, * missing passwordHash (OAuth-only user), or wrong password — the * canonical "Invalid email or password." message comes from the * factory and never reveals which leg failed (§13.8). * * NOTE: Per the migration spec, account-state checks (locked / disabled * accounts, etc.) move to PR 5 — this method preserves today's behavior. */ verifyCredentials(email: string, password: string): Promise; /** * Change user password. * * @throws NextlyError(AUTH_INVALID_CREDENTIALS) when the current password * is wrong, the user has no password (OAuth-only), or the user does not * exist — all three legs collapse to the same public message per §13.8 * to avoid leaking account state. * @throws NextlyError on DB errors. */ changePassword(userId: string, currentPassword: string, newPassword: string): Promise; /** * Generate password reset token * * @param email - User email address * @param options.disableEmail - Skip sending the reset email and always return the token * @param options.expiration - Token lifetime in seconds (defaults to TOKEN_EXPIRY_HOURS) */ generatePasswordResetToken(email: string, options?: { disableEmail?: boolean; expiration?: number; redirectPath?: string; }): Promise; /** * Consume password reset token and reset password. * * @returns Object containing the email that owned the token. * @throws NextlyError(TOKEN_EXPIRED) if the token was found but is past * its expiry — surfaces the canonical session-expired message so the * client can prompt the user to request a new reset email. * @throws NextlyError(VALIDATION_ERROR) if the token is unknown or the * new password is too weak. */ resetPasswordWithToken(token: string, newPassword: string): Promise; /** * Send email verification token. * * @returns `{}` on success when the email service handled delivery, or * `{ token }` for dev-fallback paths (no email provider, or send * failure). Mirrors the silent-success contract of * {@link generatePasswordResetToken}. * @throws NextlyError on DB errors. */ generateEmailVerificationToken(email: string, options?: { redirectPath?: string; disableEmail?: boolean; }): Promise<{ token?: string; }>; /** * Verify email with token. * * @returns The verified email address. * @throws NextlyError(VALIDATION_ERROR) when the token is unknown. * @throws NextlyError(TOKEN_EXPIRED) when the token is past expiry. * @throws NextlyError on DB errors during the verification transaction. */ verifyEmail(token: string): Promise<{ email: string; }>; /** * Mint a single-use set-password link for an existing account. * * The link is the artifact an admin hands to a new user; email is only ever * one way to deliver it. Follows the same shape as a password-reset token — * a 256-bit random value of which only the SHA-256 hash is stored, one active * token per account — but keyed on the user id and given a longer life. * * The raw token is returned to the caller and never persisted. There is no * way to recover it afterwards; mint a new one instead. */ generateInviteToken(userId: string): Promise; /** The one public error every unusable-invite path returns. */ private invalidInviteError; /** * Accept an invite: set the account's password and let it sign in. * * Clicking a link that was delivered to an address is itself proof of the * address, so acceptance sets `emailVerified` and `isActive` alongside the * password in one transaction — there is no separate verification round trip, * and no window where the account has a password but still cannot sign in. * * The failure messages do not distinguish "never existed" from "already * used" from "expired-by-a-second", to avoid confirming which invites are * live to whoever holds a guessed token. */ acceptInvite(token: string, newPassword: string): Promise; /** * Replace an admin-set password on forced first sign-in and clear the * must-change flag (ASVS 6.4.1). The update is conditional on the flag still * being set, so a replayed or concurrent call cannot re-set the password * after it has already been changed — exactly one call flips the flag, and * only that one writes the new password. * * @throws NextlyError(VALIDATION_ERROR) on a weak password. * @throws NextlyError(INVALID_INPUT) when the account is not (or is no longer) * in the must-change state. */ setInitialPassword(userId: string, newPassword: string): Promise<{ userId: string; }>; /** * Clean up expired tokens */ cleanupExpiredTokens(): Promise; } export { AuthService as A, BaseService as B, EmailService as E, PermissionService as P, RetentionRunner as R, WebhookFastDrainScheduler as W, UserExtSchemaService as b, BLOCK_FIELD_TYPE_CATALOG as bA, ALL_FIELD_TYPES as bd, AdminPlacement as bl, AuthRateLimitConfigSchema as bu, BINDABLE_KINDS as by, BLOCK_FIELD_TYPES as bz, CollectionFileManager as c3, CollectionService as c7, DynamicCollectionService as cB, EventBus as cP, FIELD_GROUP_MIGRATION_STATUSES as cU, FIELD_GROUP_SOURCE_TYPES as cV, FIELD_TYPE_BINDING_KIND as cW, CorsConfigSchema as cc, DATA_FIELD_TYPES as cj, MAX_EMAIL_PROVIDER_TYPE_LENGTH as dK, MediaService as dL, PAGINATION_DEFAULTS as dY, PLUGIN_CATEGORIES as dZ, FilterRegistry as dg, InMemoryRateLimitStore as du, STORAGE_PRIMITIVE_AS_FIELD_TYPE as fe, SYSTEM_CONTEXT as ff, SanitizationConfigSchema as fh, SecurityConfigSchema as fm, SecurityHeadersConfigSchema as fp, SecurityLimitsConfigSchema as fr, UploadSecurityConfigSchema as g3, pluginUserField as gA, pluginUserFieldBrand as gB, registerServices as gC, resetEventBus as gD, resetFilterRegistry as gE, resetHookRegistry as gF, shutdownServices as gG, Nextly as gJ, sanitizeConfig as gR, getNextly as gU, UserService as ga, bindingKindOf as gc, buildPaginatedResponse as gd, calculateOffset as ge, canBindFieldToProp as gf, clampLimit as gg, clearServices as gh, consoleLogger as gi, createPluginContext as gj, createRateLimitHeaders as gk, createRateLimiter as gl, defineCollection as gm, defineEmailProvider as gn, definePlugin as go, getEventBus as gp, getFilterRegistry as gq, getHookRegistry as gr, getService as gs, isBindablePropType as gt, isBlockFieldType as gu, isPluginCategory as gv, isServicesRegistered as gw, nextly as gx, pluginField as gy, pluginFieldBrand as gz, CollectionsHandler as h, DEFAULT_FIELD_SURFACES as h7, FIELD_TYPE_CATALOG as h8, FIELD_TYPE_VALIDATION_RULES as h9, FORM_FIELD_TYPE_CATALOG as ha, USER_FIELD_TYPE_CATALOG as hg, getFieldTypeCatalogEntry as hj, narrowFieldTypeCatalog as hk, validationRulesForFieldType as hl, RoleService as i, RolePermissionService as j, MediaService$1 as k, MediaFolderService as l, HookRegistry as p }; export type { LoginArgs as $, CodeFieldValue as C, DeleteArgs as D, FindArgs as F, GetUserResponse as G, HookType as H, CountArgs as I, CountResult as J, BulkDeleteArgs as K, Logger as L, MinimalUser$1 as M, NextlyServiceConfig as N, BulkOperationResult$2 as O, DuplicateArgs as Q, SingleHooks as S, SingleSlug as T, UserConfig as U, FindSingleArgs as V, RowFromSingleSlug as X, UpdateSingleArgs as Y, FindSinglesArgs as Z, SingleListResult as _, CollectionConfig as a, Permission as a$, UserContext$2 as a0, AuthResult as a1, RegisterArgs as a2, ChangePasswordArgs as a3, ForgotPasswordArgs as a4, ResetPasswordArgs as a5, VerifyEmailArgs as a6, ServiceMap as a7, FindUsersArgs as a8, User as a9, SetDefaultProviderArgs as aA, TestEmailProviderArgs as aB, FindEmailTemplatesArgs as aC, EmailTemplateRecord as aD, FindEmailTemplateByIDArgs as aE, FindEmailTemplateBySlugArgs as aF, CreateEmailTemplateArgs as aG, UpdateEmailTemplateArgs as aH, DeleteEmailTemplateArgs as aI, PreviewEmailTemplateArgs as aJ, FindUserFieldsArgs as aK, UserFieldDefinitionRecord as aL, FindUserFieldByIDArgs as aM, CreateUserFieldArgs as aN, UpdateUserFieldArgs as aO, DeleteUserFieldArgs as aP, ReorderUserFieldsArgs as aQ, SendEmailArgs as aR, SendEmailResult as aS, SendTemplateEmailArgs as aT, FindRolesArgs as aU, Role as aV, FindRoleByIDArgs as aW, CreateRoleArgs as aX, UpdateRoleArgs as aY, DeleteRoleArgs as aZ, GetRolePermissionsArgs as a_, FindUserByIDArgs as aa, CreateUserArgs as ab, UpdateUserArgs as ac, DeleteUserArgs as ad, UploadMediaArgs as ae, MediaFile as af, FindMediaArgs as ag, FindMediaByIDArgs as ah, UpdateMediaArgs as ai, DeleteMediaArgs as aj, BulkDeleteMediaArgs as ak, ListFoldersArgs as al, MediaFolder as am, CreateFolderArgs as an, FindFormsArgs as ao, FindFormBySlugArgs as ap, SubmitFormArgs as aq, SubmitFormResult as ar, FormSubmissionsArgs as as, FieldGroupsNamespace as at, FindEmailProvidersArgs as au, EmailProviderRecord as av, FindEmailProviderByIDArgs as aw, CreateEmailProviderArgs as ax, UpdateEmailProviderArgs as ay, DeleteEmailProviderArgs as az, CollectionAdminConfig as b$, SetRolePermissionsArgs as b0, FindPermissionsArgs as b1, FindPermissionByIDArgs as b2, CreatePermissionArgs as b3, DeletePermissionArgs as b4, CheckAccessArgs as b5, FieldConfig as b6, IndexConfig as b7, PluginDefinition as b8, PluginFieldType as b9, BaseFieldConfig as bB, BatchOperationResult as bC, BeforeOperationArgs as bD, BeforeOperationContext as bE, BindingEndpoint as bF, BindingValueKind as bG, BlockFieldCatalogType as bH, BuildPaginatedResponseOptions as bI, BulkOperationResult as bJ, CellComponentProps as bK, Challenge as bL, ChallengeDefinition as bM, CheckApiKeyArgs as bN, CheckApiKeyResult as bO, CheckboxFieldAdminOptions as bP, CheckboxFieldConfig as bQ, CheckboxFieldValue as bR, ChipsFieldAdminOptions as bS, ChipsFieldConfig as bT, ChipsFieldValue as bU, CodeEditorOptions as bV, CodeFieldAdminOptions as bW, CodeFieldConfig as bX, CodeLanguage as bY, Collection as bZ, CollectionAccessControl as b_, FieldSurface as ba, SingleConfig as bb, FieldGroupConfig as bc, AccessControlContext as be, AccessControlFunction as bf, AccessFunction as bg, Action as bh, AdminBrandingColors as bi, AdminBrandingConfig as bj, AdminConfig as bk, ApiKeyMeta as bm, ApiKeyResult as bn, ApiKeyTokenType as bo, AuthHookName as bp, AuthHooks as bq, AuthInput as br, AuthOutcome as bs, AuthRateLimitConfigInput as bt, AuthStrategy as bv, AuthUser as bw, AuthorableFieldConfig as bx, ListUsersResponse as c, FieldDefinition as c$, CollectionAdminOptions as c0, CollectionArtifacts as c1, CollectionEntry as c2, CollectionLabels as c4, CollectionPagination as c5, CollectionSchemaDefinition as c6, CollectionSource as c8, ComponentPath as c9, DynamicCollectionRecord as cA, DynamicFieldGroupInsert as cC, DynamicFieldGroupRecord as cD, DynamicFieldType as cE, EmailConfig as cF, EmailFieldAdminOptions as cG, EmailFieldConfig as cH, EmailFieldValue as cI, EmailProviderAdapter as cJ, EmailProviderCapabilities as cK, EmailProviderConfigField as cL, EmailProviderDefinition as cM, EmailProviderDescriptor as cN, EmailTemplateFn as cO, EventEnvelope as cQ, EventHandler as cR, EventName as cS, ExpiresIn as cT, FieldAccess as cX, FieldAdminOptions as cY, FieldComponentProps as cZ, FieldCondition as c_, CorsConfig as ca, CorsConfigInput as cb, CreateApiKeyArgs as cd, CreateCollectionInput as ce, CreateFieldGroupArgs as cf, CreateFolderInput as cg, CreateUserInput as ch, CustomEndpoint as ci, DataFieldConfig as ck, DataFieldType as cl, DataFromCollectionSlug as cm, DataFromFieldGroupSlug as cn, DataFromSingleSlug as co, DatabaseConfig as cp, DateFieldAdminOptions as cq, DateFieldConfig as cr, DateFieldValue as cs, DatePickerAppearance as ct, DatePickerOptions as cu, DeleteFieldGroupArgs as cv, DirectAPIConfig as cw, RequestContext as cx, DrizzleDB as cy, DynamicCollectionInsert as cz, RequestActor as d, PaginatedResult as d$, FieldGroupAdminOptions as d0, FieldGroupDefinition as d1, FieldGroupFieldConfig as d2, FieldGroupLabel as d3, FieldGroupMigrationStatus$1 as d4, FieldGroupSlug as d5, FieldGroupSource as d6, FieldHooks as d7, FieldStoragePrimitive as d8, FieldType as d9, JSONSchemaDefinition as dA, JSONSchemaProperty as dB, JSONSchemaType as dC, JsonObject as dD, JsonValue as dE, ListApiKeysArgs as dF, ListCollectionsOptions as dG, ListMediaOptions as dH, ListUsersQueryOptions as dI, LoginResult as dJ, MediaType as dM, Middleware as dN, MigrationRecord as dO, MigrationRecordInsert as dP, MigrationRecordStatus as dQ, MigrationStatus as dR, MinimalUser as dS, NextlyConfig as dT, NumberFieldAdminOptions as dU, NumberFieldConfig as dV, NumberFieldValue as dW, NumberFilterOperator as dX, PaginatedResponse as d_, FieldValidation as da, Filter as db, FilterComponentProps as dc, FilterName as dd, FilterOptionsArgs as de, FilterOptionsFunction as df, FindApiKeyByIDArgs as dh, FindFieldGroupBySlugArgs as di, FindFieldGroupsArgs as dj, FolderContents as dk, FormsConfig as dl, GeneratedTypes as dm, GroupFieldAdminOptions as dn, GroupFieldConfig as dp, GroupFieldConfig_FieldConfig as dq, GroupFieldValue as dr, HookContext as ds, HttpMethod as dt, InProcessRow as dv, JSONEditorOptions as dw, JSONFieldAdminOptions as dx, JSONFieldConfig as dy, JSONFieldValue as dz, UserMutationResponse as e, RepeaterFieldConfig as e$, PaginationMeta as e0, PaginationOptions as e1, PasswordFieldAdminOptions as e2, PasswordFieldConfig as e3, PasswordFieldValue as e4, PasswordHasher as e5, PermissionSlug as e6, PluginActionRegistry as e7, PluginAdminAppearance as e8, PluginAdminConfig as e9, PluginRouteContext as eA, PluginRouteHandler as eB, PopulateOptions as eC, QueryOperator as eD, QueryOptions as eE, RadioFieldAdminOptions as eF, RadioFieldConfig as eG, RadioFieldValue as eH, RadioLayout as eI, RateLimitConfig as eJ, RateLimitRecord as eK, RateLimitResult as eL, RateLimitStore as eM, RateLimitingConfig as eN, RegisteredEmailProvider as eO, RelationshipAppearance as eP, RelationshipFieldAdminOptions as eQ, RelationshipFieldConfig as eR, RelationshipFieldValue as eS, RelationshipFilterOptions as eT, RelationshipFilterOptionsArgs as eU, RelationshipFilterOptionsFunction as eV, RelationshipFilterQuery as eW, RelationshipPolymorphicValue as eX, RelationshipSingleValue as eY, RelationshipSortOptions as eZ, RepeaterFieldAdminOptions as e_, PluginAdminContributions as ea, PluginAdminPage as eb, PluginAdminWidget as ec, PluginCategory as ed, PluginCollectionService as ee, PluginCollectionView as ef, PluginContext as eg, PluginContributions as eh, PluginDataFieldConfig as ei, PluginEmailProvider as ej, PluginEmailTemplate as ek, PluginFieldCodegen as el, PluginFieldCodegenImport as em, PluginFieldInput as en, PluginFieldInstance as eo, PluginFieldIssue as ep, PluginFieldValidateArgs as eq, PluginFieldValidationResult as er, PluginFilterRegistry as es, PluginHookRegistry as et, PluginMenuItem as eu, PluginNavSection as ev, PluginOverride as ew, PluginPermission as ex, PluginRole as ey, PluginRoute as ez, GetAccountsResponse as f, UploadFilterQuery as f$, RepeaterFieldLabels as f0, RepeaterFieldValue as f1, RepeaterRowLabelProps as f2, RepeaterRowValue as f3, RequestContext$2 as f4, ResendConfig as f5, RevokeApiKeyArgs as f6, RichTextFeature as f7, RichTextFieldAdminOptions as f8, RichTextFieldConfig as f9, SingleAdminOptions as fA, SingleLabel as fB, SmtpConfig as fC, SortOptions as fD, StoredHookConfig as fE, StoredHookType as fF, StringFilterOperator as fG, TextFieldAdminOptions as fH, TextFieldConfig as fI, TextFieldValue as fJ, TextareaFieldAdminOptions as fK, TextareaFieldConfig as fL, TextareaFieldValue as fM, TypeScriptConfig as fN, UpdateApiKeyArgs as fO, UpdateCollectionInput as fP, UpdateFieldGroupArgs as fQ, UpdateFolderInput as fR, UpdateMediaInput as fS, UpdateUserInput as fT, UploadFieldAdminOptions as fU, UploadFieldConfig as fV, UploadFieldValue as fW, UploadFileData as fX, UploadFilterOptions as fY, UploadFilterOptionsArgs as fZ, UploadFilterOptionsFunction as f_, RichTextFieldValue as fa, RichTextNode as fb, RichTextValue as fc, RouteMethod as fd, SanitizationConfigInput as fg, SanitizedRateLimitingConfig as fi, ScheduledTask as fj, SecurityConfig as fk, SecurityConfigInput as fl, SecurityHeadersConfig as fn, SecurityHeadersConfigInput as fo, SecurityLimitsConfigInput as fq, SelectFieldAdminOptions as fs, SelectFieldConfig as ft, SelectFieldValue as fu, SelectOption as fv, SendLayerConfig as fw, ServiceDeps as fx, ServiceOpts as fy, SingleAccessControl as fz, UnlinkAccountResult as g, UserFieldSource as g$, UploadMediaInput as g0, UploadPolymorphicValue as g1, UploadSecurityConfigInput as g2, UploadSingleValue as g4, UserAdminOptions as g5, UserFieldConfig as g6, UserFieldType as g7, UserPluginFieldConfig as g8, UserPluginFieldInput as g9, LocalizationConfig as gH, CollectionAccessRules as gI, FieldCondition$1 as gK, FieldHookContext as gL, FieldHookHandler as gM, LocaleInput as gN, RequestContext$1 as gO, ResolvedLocale as gP, SanitizedLocalizationConfig as gQ, CacheRevalidator as gS, RevalidationIntent as gT, SingleAccessRules as gV, SingleSource as gW, ResolvedVersionsConfig as gX, RevalidateConfig as gY, StoredWebhookRecording as gZ, SingleMigrationStatus as g_, WhereFilter as gb, EmailProviderType as h0, EmailTemplateVariable as h1, EmailAttachmentInput as h2, VersionScopeKind as h3, VersionStatus as h4, SupportedDialect$2 as h5, CollectionLabels$1 as h6, FieldTypeCatalogEntry as hb, FieldTypeCategory as hc, FieldValidationRule as hd, FormFieldCatalogType as he, FormSurfaceFieldType as hf, UserFieldCatalogType as hh, UserSurfaceFieldType as hi, BeforeOperationHandler as m, HookContextPhase as n, HookHandler as o, CollectionHooks as q, SanitizedNextlyConfig as r, CollectionSlug as s, ListResult as t, RowFromCollectionSlug as u, FindByIDArgs as v, CreateArgs as w, MutationResult as x, UpdateArgs as y, DeleteResult as z };