@murumets-ee/entity 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +472 -136
- package/dist/admin/index.d.mts.map +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/index.mjs.map +1 -1
- package/dist/index.d.mts +129 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/query/index.d.mts +98 -13
- package/dist/query/index.d.mts.map +1 -1
- package/dist/query/index.mjs +1 -1
- package/dist/query/index.mjs.map +1 -1
- package/dist/refs/index.d.mts +17 -0
- package/dist/refs/index.d.mts.map +1 -1
- package/dist/refs/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/fields/builders.ts","../src/behaviors/auditable.ts","../src/behaviors/hierarchical.ts","../src/behaviors/publishable.ts","../src/behaviors/revisionable.ts","../src/behaviors/sluggable.ts","../src/behaviors/timestamped.ts","../src/behaviors/index.ts","../src/count-cache.ts","../src/count-estimate.ts","../src/cursor.ts","../src/define-entity.ts","../src/refs/errors.ts","../src/refs/schema.ts","../src/schema-generator.ts","../src/shared/entity-data-ops.ts"],"sourcesContent":["/**\n * Fluent API for building field definitions\n * Provides type-safe field builders with sensible defaults.\n *\n * Each builder uses a `const` generic parameter on the config to preserve\n * literal types (e.g., `required: true` stays `true`, not `boolean`).\n * This enables compile-time type inference in the entity system.\n */\n\nimport type {\n BlockDefinitionRef,\n BlocksField,\n BooleanField,\n DateField,\n IdField,\n JsonField,\n MediaField,\n NumberField,\n ReferenceField,\n RichTextField,\n SelectField,\n SlugField,\n TextField,\n} from './base.js'\n\nexport const field = {\n /**\n * ID field (auto-added to every entity)\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n id: <const C extends Partial<IdField> = {}>(config?: C): IdField & C =>\n ({\n type: 'id',\n required: true,\n indexed: true,\n ...config,\n }) as IdField & C,\n\n /**\n * Text field\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n text: <const C extends Partial<TextField> = {}>(config?: C): TextField & C =>\n ({\n type: 'text',\n ...config,\n }) as TextField & C,\n\n /**\n * Number field\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n number: <const C extends Partial<NumberField> = {}>(config?: C): NumberField & C =>\n ({\n type: 'number',\n ...config,\n }) as NumberField & C,\n\n /**\n * Boolean field\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n boolean: <const C extends Partial<BooleanField> = {}>(config?: C): BooleanField & C =>\n ({\n type: 'boolean',\n default: false,\n ...config,\n }) as BooleanField & C,\n\n /**\n * Date field\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n date: <const C extends Partial<DateField> = {}>(config?: C): DateField & C =>\n ({\n type: 'date',\n ...config,\n }) as DateField & C,\n\n /**\n * Select field (enum)\n * Preserves literal option types for type inference.\n * String options → varchar column, number options → integer column.\n * e.g. field.select({ options: ['news', 'tutorial'] }) infers 'news' | 'tutorial'\n * e.g. field.select({ options: [0, 1, 2, 3] }) infers 0 | 1 | 2 | 3\n */\n select: <\n const O extends readonly string[],\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n const C extends Omit<Partial<SelectField>, 'options'> = {},\n >(\n config: { options: O } & C,\n ): SelectField & { options: O } & C =>\n ({\n type: 'select',\n ...config,\n }) as SelectField & { options: O } & C,\n\n /**\n * Reference field (foreign key to another entity)\n * Preserves literal cardinality to distinguish string vs string[] in inferred types.\n */\n reference: <\n C extends 'one' | 'many' = 'one',\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n const R extends Partial<Omit<ReferenceField, 'entity' | 'cardinality'>> = {},\n >(\n config: { entity: string; cardinality?: C } & R,\n ): ReferenceField & { cardinality: C } & R =>\n ({\n type: 'reference',\n cardinality: 'one' as C,\n onDelete: 'set-null',\n ...config,\n }) as ReferenceField & { cardinality: C } & R,\n\n /**\n * Media field (file upload)\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n media: <const C extends Partial<MediaField> = {}>(config?: C): MediaField & C =>\n ({\n type: 'media',\n ...config,\n }) as MediaField & C,\n\n /**\n * Rich text field (WYSIWYG editor)\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n richtext: <const C extends Partial<RichTextField> = {}>(config?: C): RichTextField & C =>\n ({\n type: 'richtext',\n ...config,\n }) as RichTextField & C,\n\n /**\n * Slug field (URL-safe string, auto-generated from source field)\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n slug: <const C extends Partial<SlugField> = {}>(\n config: Pick<SlugField, 'from'> & C,\n ): SlugField & C =>\n ({\n type: 'slug',\n unique: true,\n indexed: true,\n ...config,\n }) as SlugField & C,\n\n /**\n * JSON field (arbitrary JSON data, stored as JSONB)\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n json: <const C extends Partial<JsonField> = {}>(config?: C): JsonField & C =>\n ({\n type: 'json',\n ...config,\n }) as JsonField & C,\n\n /**\n * Blocks field — ordered array of typed content blocks\n * Preserves literal block tuple type for discriminated union inference.\n */\n blocks: <const B extends readonly BlockDefinitionRef[]>(config: {\n blocks: B\n min?: number\n max?: number\n localized?: boolean\n }): BlocksField & { blocks: B } =>\n ({\n type: 'blocks' as const,\n ...config,\n }) as BlocksField & { blocks: B },\n}\n","/**\n * Auditable behavior\n * Adds createdBy, updatedBy, createdAt, updatedAt fields + auto-set logic.\n *\n * The user id is read from the BehaviorContext passed in by AdminClient.\n * AdminClient resolves it once per request from its `contextResolver` —\n * the entity package never imports @murumets-ee/core or touches ALS.\n */\n\nimport { field } from '../fields/builders.js'\nimport type { AuditableFields } from '../types/infer.js'\nimport type { Behavior } from './types.js'\n\nexport function auditable(): Behavior<AuditableFields> {\n return {\n name: 'auditable',\n fields: {\n createdBy: field.text(),\n updatedBy: field.text(),\n createdAt: field.date({ indexed: true }),\n updatedAt: field.date({ indexed: true }),\n },\n hooks: {\n beforeCreate: async (data, ctx) => {\n data.createdAt = new Date()\n data.updatedAt = new Date()\n const userId = ctx?.user?.id\n if (userId) {\n data.createdBy = userId\n data.updatedBy = userId\n }\n return data\n },\n beforeUpdate: async (_id, data, ctx) => {\n data.updatedAt = new Date()\n const userId = ctx?.user?.id\n if (userId) {\n data.updatedBy = userId\n }\n return data\n },\n },\n }\n}\n","/**\n * Hierarchical behavior\n * Adds parentId, path, depth fields for materialized path tree structures.\n *\n * Path format: /uuid1/uuid2/uuid3\n * - Root items: /{ownId}\n * - Children: /{rootId}/.../{parentId}/{ownId}\n *\n * The behavior declares fields and sets safe defaults in hooks.\n * Heavy lifting (path computation, circular ref validation, descendant cascading)\n * is done by TaxonomyClient which has DB access.\n */\n\nimport { field } from '../fields/builders.js'\nimport type { HierarchicalFields } from '../types/infer.js'\nimport type { Behavior } from './types.js'\n\nexport interface HierarchicalOptions {\n /**\n * What happens when deleting a node that has children.\n * - 'restrict': Prevent deletion (default)\n * - 'cascade': Delete all descendants\n * - 'reparent': Move children to the deleted node's parent\n */\n onDelete?: 'restrict' | 'cascade' | 'reparent'\n}\n\nexport function hierarchical(_options?: HierarchicalOptions): Behavior<HierarchicalFields> {\n return {\n name: 'hierarchical',\n fields: {\n parentId: field.reference({ entity: '_self', required: false }),\n path: field.text({ indexed: true, maxLength: 2048 }),\n depth: field.number({ integer: true, default: 0, indexed: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n // Set defaults for root-level items.\n // TaxonomyClient overrides these after insert when it has the ID.\n if (!data.parentId) {\n data.depth = 0\n }\n return data\n },\n },\n }\n}\n","/**\n * Publishable behavior\n * Adds status (draft/published) and publishedAt fields + auto-set logic\n */\n\nimport { field } from '../fields/builders.js'\nimport type { PublishableFields } from '../types/infer.js'\nimport type { Behavior } from './types.js'\n\nexport function publishable(): Behavior<PublishableFields> {\n return {\n name: 'publishable',\n fields: {\n status: field.select({\n options: ['draft', 'published'] as const,\n default: 'draft',\n indexed: true,\n }),\n publishedAt: field.date({ indexed: true }),\n },\n hooks: {\n beforeUpdate: async (_id, data) => {\n // Auto-set publishedAt when status changes to published (if not already set)\n if (data.status === 'published' && !data.publishedAt) {\n data.publishedAt = new Date()\n }\n return data\n },\n },\n }\n}\n","/**\n * Revisionable behavior\n * Adds _version field + auto-increment on update\n */\n\nimport { field } from '../fields/builders.js'\nimport type { RevisionableFields } from '../types/infer.js'\nimport type { Behavior } from './types.js'\n\nexport function revisionable(): Behavior<RevisionableFields> {\n return {\n name: 'revisionable',\n fields: {\n _version: field.number({ required: true, default: 1, integer: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n data._version = 1\n return data\n },\n beforeUpdate: async (_id, data) => {\n // Increment version on every update\n data._version = (Number(data._version) || 0) + 1\n return data\n },\n },\n }\n}\n","/**\n * Sluggable behavior\n * Adds slug field + auto-generation from source field\n */\n\nimport { field } from '../fields/builders.js'\nimport type { SluggableFields } from '../types/infer.js'\nimport type { Behavior } from './types.js'\n\nexport interface SluggableOptions {\n /** Make the slug translatable — each locale gets its own slug. Default: false */\n translatable?: boolean\n}\n\n/**\n * Convert text to URL-safe slug\n */\nexport function slugify(text: string): string {\n return text\n .toLowerCase()\n .trim()\n .replace(/[^\\w\\s-]/g, '') // Remove non-word chars except spaces and hyphens\n .replace(/[\\s_-]+/g, '-') // Replace spaces, underscores with single hyphen\n .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens\n}\n\nexport function sluggable(\n sourceField: string,\n options?: SluggableOptions,\n): Behavior<SluggableFields> {\n return {\n name: 'sluggable',\n fields: {\n slug: field.slug({\n from: sourceField,\n ...(options?.translatable ? { translatable: true } : {}),\n }),\n },\n hooks: {\n beforeCreate: async (data) => {\n // Auto-generate slug from source field if not provided\n if (!data.slug && data[sourceField]) {\n data.slug = slugify(String(data[sourceField]))\n }\n return data\n },\n beforeUpdate: async (_id, data) => {\n // Re-generate slug if source field changed and slug not manually set\n if (data[sourceField] && !data.slug) {\n data.slug = slugify(String(data[sourceField]))\n }\n return data\n },\n },\n }\n}\n","/**\n * Timestamped behavior\n * Adds createdAt and updatedAt fields with auto-set logic.\n * Lighter alternative to auditable() when you don't need createdBy/updatedBy tracking.\n */\n\nimport { field } from '../fields/builders.js'\nimport type { Behavior } from './types.js'\n\nexport type TimestampedFields = {\n createdAt: ReturnType<typeof field.date>\n updatedAt: ReturnType<typeof field.date>\n}\n\nexport function timestamped(): Behavior<TimestampedFields> {\n return {\n name: 'timestamped',\n fields: {\n createdAt: field.date({ indexed: true }),\n updatedAt: field.date({ indexed: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n data.createdAt = new Date()\n data.updatedAt = new Date()\n return data\n },\n beforeUpdate: async (_id, data) => {\n data.updatedAt = new Date()\n return data\n },\n },\n }\n}\n","/**\n * Behavior exports\n * All behaviors are exported under the `behavior` namespace\n */\n\nimport { auditable } from './auditable.js'\nimport { hierarchical } from './hierarchical.js'\nimport { publishable } from './publishable.js'\nimport { revisionable } from './revisionable.js'\nimport { sluggable } from './sluggable.js'\nimport { timestamped } from './timestamped.js'\n\nexport { publishable, auditable, sluggable, revisionable, hierarchical, timestamped }\nexport type { SluggableOptions } from './sluggable.js'\nexport { slugify } from './sluggable.js'\nexport type { Behavior, BehaviorContext, BehaviorFactory } from './types.js'\n\n/**\n * Behavior namespace for fluent API\n */\nexport const behavior = {\n publishable,\n auditable,\n sluggable,\n revisionable,\n hierarchical,\n timestamped,\n}\n","/**\n * In-memory TTL cache for COUNT(*) query results.\n *\n * Reduces database load on paginated list pages where the total count\n * is recalculated on every pagination/sort/search interaction. The cache\n * is per-process (not shared across workers) with a short TTL (default 5s)\n * so counts are at most a few seconds stale.\n *\n * Cache keys include the entity name + serialized WHERE clause, so filtered\n * and unfiltered counts are cached independently.\n */\n\n/**\n * Interface for count cache implementations.\n * Used in client configs to avoid TypeScript private-field structural incompatibility\n * across separate .d.ts files.\n */\nexport interface CountCacheLike {\n get(key: string): number | undefined\n set(key: string, count: number): void\n /**\n * Cache-or-compute with single-flight semantics.\n *\n * If the entry is cached and unexpired, returns it immediately (no promise\n * allocation in the hot path). Otherwise, if a refresh is already in flight\n * for the same key, returns that in-flight promise instead of starting a\n * second one — preventing the thundering-herd burst of identical\n * `count(*)` queries when N concurrent requests cross the TTL boundary\n * together.\n */\n getOrCompute(key: string, compute: () => Promise<number>): Promise<number> | number\n invalidate(prefix: string): void\n}\n\ninterface CountCacheEntry {\n count: number\n expiresAt: number\n}\n\nexport class CountCache implements CountCacheLike {\n private cache = new Map<string, CountCacheEntry>()\n private inflight = new Map<string, Promise<number>>()\n private ttlMs: number\n\n constructor(ttlMs = 5000) {\n this.ttlMs = ttlMs\n }\n\n /**\n * Get a cached count. Returns undefined on miss or expired entry.\n */\n get(key: string): number | undefined {\n const entry = this.cache.get(key)\n if (!entry || Date.now() > entry.expiresAt) {\n if (entry) this.cache.delete(key)\n return undefined\n }\n return entry.count\n }\n\n /**\n * Cache a count result with the configured TTL.\n */\n set(key: string, count: number): void {\n this.cache.set(key, { count, expiresAt: Date.now() + this.ttlMs })\n }\n\n /**\n * Cache-or-compute with single-flight semantics. See `CountCacheLike` doc.\n *\n * Cache hits return synchronously (the call site keeps the await but no\n * actual microtask is scheduled). Cache misses register an in-flight\n * promise so concurrent callers share one DB round-trip; the promise is\n * removed once it resolves (or rejects) so the next miss can refresh.\n * On rejection, nothing is cached — the next caller retries.\n */\n getOrCompute(key: string, compute: () => Promise<number>): Promise<number> | number {\n const cached = this.get(key)\n if (cached !== undefined) return cached\n\n const existing = this.inflight.get(key)\n if (existing) return existing\n\n const promise = compute()\n .then((count) => {\n this.set(key, count)\n return count\n })\n .finally(() => {\n // Always clear inflight, even on error, so the next miss can retry.\n this.inflight.delete(key)\n })\n this.inflight.set(key, promise)\n return promise\n }\n\n /**\n * Invalidate all cache entries for a given entity name.\n * Matches the exact entity name OR any key where the entity is followed\n * by one of the cache-key separators (`:` for WHERE, `@` for scope).\n * Avoids accidental cross-entity invalidation (e.g. invalidating 'user'\n * must not clear 'users:where=...' entries).\n */\n invalidate(entityName: string): void {\n for (const key of this.cache.keys()) {\n // Strip optional 'query:' prefix used by QueryClient cache keys\n const stripped = key.startsWith('query:') ? key.slice('query:'.length) : key\n if (\n stripped === entityName ||\n stripped.startsWith(`${entityName}:`) ||\n stripped.startsWith(`${entityName}@`)\n ) {\n this.cache.delete(key)\n }\n }\n }\n\n /**\n * Remove all expired entries. Useful for periodic cleanup in long-running processes.\n */\n prune(): void {\n const now = Date.now()\n for (const [key, entry] of this.cache.entries()) {\n if (now > entry.expiresAt) {\n this.cache.delete(key)\n }\n }\n }\n\n /**\n * Clear the entire cache.\n */\n clear(): void {\n this.cache.clear()\n }\n\n /**\n * Number of entries currently in the cache (including expired ones not yet pruned).\n */\n get size(): number {\n return this.cache.size\n }\n}\n","/**\n * Postgres row count estimation using pg_class statistics.\n *\n * For unfiltered counts on large tables, querying `pg_class.reltuples`\n * is effectively instant (no table scan) and returns a good approximation\n * that is updated by VACUUM and ANALYZE. This is suitable for pagination\n * totals where exact precision is not critical.\n *\n * SECURITY: The table name is NOT interpolated into SQL — it is passed as a\n * parameterized value to the `relname = $1` comparison. This prevents SQL injection.\n */\n\nimport { sql } from 'drizzle-orm'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\n\n/**\n * Estimate the total row count for a table using Postgres statistics.\n *\n * Returns the approximate row count from `pg_class.reltuples`, which is\n * updated by VACUUM/ANALYZE. Returns 0 if the table is not found or\n * statistics are not yet available (e.g., freshly created table).\n *\n * @param db - Drizzle Postgres database instance\n * @param tableName - The Postgres table name (without schema prefix)\n * @returns Estimated row count (non-negative integer)\n */\nexport async function estimateRowCount(db: PostgresJsDatabase, tableName: string): Promise<number> {\n const result = await db.execute(\n sql`SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = ${tableName}`,\n )\n\n const rows = Array.isArray(result) ? result : ((result as { rows?: unknown[] }).rows ?? [])\n const row = rows[0] as { estimate?: string | number } | undefined\n const estimate = Number(row?.estimate ?? 0)\n\n // reltuples can be -1 for tables that have never been analyzed\n return estimate > 0 ? estimate : 0\n}\n","/**\n * Cursor-based (keyset) pagination utilities.\n *\n * Cursor pagination avoids the performance cliff of OFFSET at scale (1M+ rows).\n * Instead of `OFFSET N`, it uses a WHERE condition:\n * `WHERE (sortField < lastValue) OR (sortField = lastValue AND id < lastId)`\n * which Postgres can serve from an index in constant time.\n *\n * The cursor is opaque to the client — base64-encoded JSON.\n *\n * Security:\n * - `field` must be whitelisted against the entity's actual fields\n * - `id` must be a valid UUID\n * - `value` is parameterized (never interpolated into SQL)\n * - Malformed cursors return null (caller returns 400)\n */\n\nimport { and, eq, getTableColumns, gt, lt, or, type SQL } from 'drizzle-orm'\nimport type { PgTable } from 'drizzle-orm/pg-core'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Cursor input for keyset pagination. */\nexport interface CursorInput {\n /** Sort field name (e.g. 'createdAt'). Must be a real column on the entity. */\n field: string\n /** Last seen value of the sort field. */\n value: string | number\n /** Sort direction — must match the ORDER BY direction. */\n direction: 'asc' | 'desc'\n /** Tie-breaker: last seen entity ID. Required for non-unique sort fields. */\n id?: string\n}\n\n/** Decoded cursor (internal, after validation). */\ninterface DecodedCursor {\n field: string\n value: string | number\n direction: 'asc' | 'desc'\n id?: string\n}\n\n// ---------------------------------------------------------------------------\n// UUID validation (same format used throughout the toolkit)\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n// ---------------------------------------------------------------------------\n// Encode / decode\n// ---------------------------------------------------------------------------\n\n/**\n * Encode a cursor for API transport (base64url).\n * Built from the last item in a result set.\n */\nexport function encodeCursor(\n item: Record<string, unknown>,\n sortField: string,\n direction: 'asc' | 'desc',\n): string {\n const payload: CursorInput = {\n field: sortField,\n value: item[sortField] as string | number,\n direction,\n id: item.id as string | undefined,\n }\n return btoa(JSON.stringify(payload))\n}\n\n/**\n * Decode and validate a cursor string from query params.\n * Returns null if the cursor is malformed, tampered, or invalid.\n *\n * Security: the `field` value is NOT validated here — the caller must\n * whitelist it against the entity's actual columns.\n */\nexport function decodeCursor(encoded: string): DecodedCursor | null {\n try {\n const json = atob(encoded)\n const parsed: unknown = JSON.parse(json)\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n\n // Validate field\n if (typeof obj.field !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(obj.field)) {\n return null\n }\n\n // Validate value (string or number)\n if (typeof obj.value !== 'string' && typeof obj.value !== 'number') {\n return null\n }\n\n // Validate direction\n if (obj.direction !== 'asc' && obj.direction !== 'desc') {\n return null\n }\n\n // Validate id (optional, must be UUID if present)\n if (obj.id !== undefined) {\n if (typeof obj.id !== 'string' || !UUID_RE.test(obj.id)) {\n return null\n }\n }\n\n return {\n field: obj.field,\n value: obj.value,\n direction: obj.direction,\n id: obj.id as string | undefined,\n }\n } catch {\n return null\n }\n}\n\n// ---------------------------------------------------------------------------\n// SQL condition builder\n// ---------------------------------------------------------------------------\n\n/**\n * Build the keyset WHERE condition from a decoded cursor.\n *\n * For DESC: `WHERE (field < value) OR (field = value AND id < cursorId)`\n * For ASC: `WHERE (field > value) OR (field = value AND id > cursorId)`\n *\n * The caller must verify that `cursor.field` exists on the table before calling.\n *\n * @param table - Drizzle table with columns\n * @param cursor - Decoded and validated cursor\n * @returns SQL condition, or null if the field doesn't exist on the table\n */\nexport function buildCursorCondition(table: PgTable, cursor: DecodedCursor): SQL | null {\n const cols = getTableColumns(table)\n const column = cols[cursor.field]\n if (!column) return null\n\n const isDesc = cursor.direction === 'desc'\n const compare = isDesc ? lt : gt\n\n // Primary condition: sort field passes the cursor value\n const fieldCondition = compare(column, cursor.value)\n\n // Without tie-breaker ID, use simple comparison\n if (!cursor.id) {\n return fieldCondition\n }\n\n // With tie-breaker: (field < value) OR (field = value AND id < cursorId)\n const idColumn = cols.id\n if (!idColumn) return fieldCondition\n\n const idCondition = compare(idColumn, cursor.id)\n // Drizzle's `or`/`and` return SQL when called with at least one defined arg.\n // Fall back to fieldCondition rather than asserting non-null.\n return or(fieldCondition, and(eq(column, cursor.value), idCondition)) ?? fieldCondition\n}\n","/**\n * Entity definition API\n * The core function for defining entities with full type inference.\n *\n * The generic parameters are inferred from the call site:\n * - F is inferred from `definition.fields`\n * - B is inferred from `definition.behaviors` (as a tuple)\n *\n * The returned Entity carries the complete field map:\n * { id: IdField } & BehaviorFields & UserFields\n */\n\nimport type { EntityAdminConfig } from './admin-config.js'\nimport type { Behavior } from './behaviors/types.js'\nimport type { FieldConfig, IdField } from './fields/base.js'\nimport { field } from './fields/builders.js'\n\n/**\n * Entity definition input (without behaviors — behaviors are typed separately\n * on the `defineEntity` function to enable tuple inference).\n *\n * @typeParam F - The literal field map. Inferred automatically from the call site.\n */\nexport interface EntityDefinition<\n F extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n name: string\n kind?: 'collection' | 'singleton'\n fields: F\n scope?: 'global' | 'team' | 'user'\n access?: {\n view?: string\n create?: string\n update?: string\n delete?: string\n }\n /** Admin UI configuration — controls sidebar, list, and form display */\n admin?: EntityAdminConfig\n}\n\n/**\n * Full input for defineEntity (fields + behaviors + other config).\n * Behaviors are typed as a tuple `B` for type extraction.\n */\nexport type EntityInput<\n F extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n B extends Behavior[] = Behavior[],\n> = EntityDefinition<F> & { behaviors?: B }\n\n/**\n * A fully resolved entity with merged behavior fields.\n * @typeParam AllFields - The complete field map (id + behaviors + user fields).\n */\nexport interface Entity<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n name: string\n kind?: 'collection' | 'singleton'\n fields: Record<string, FieldConfig>\n behaviors?: Behavior[]\n scope?: 'global' | 'team' | 'user'\n access?: {\n view?: string\n create?: string\n update?: string\n delete?: string\n }\n /** Admin UI configuration — controls sidebar, list, and form display */\n admin?: EntityAdminConfig\n allFields: AllFields\n}\n\n/**\n * Extract and intersect behavior field types from a behaviors tuple.\n * Walks the tuple recursively (depth bounded by number of behaviors, max ~5).\n *\n * Inferring `F` via `Behavior<infer F1>` is unreliable for inline hook-only\n * behaviors: when the literal has no `fields` property, TS falls back to the\n * `Behavior` constraint bound (`Record<string, FieldConfig>`) rather than the\n * declared default. That bound would widen every `AllFields[K]` access to\n * `FieldConfig` and break per-field type inference downstream (e.g. reference\n * cardinality, select option literals). Instead, derive the field map from\n * the behavior's `fields` property directly — if absent/undefined, contribute\n * nothing (empty object) to the intersection.\n */\ntype BehaviorFieldsOf<B> = B extends { fields?: infer F }\n ? F extends Record<string, FieldConfig>\n ? F\n : // biome-ignore lint/complexity/noBannedTypes: empty object for hook-only behaviors (fields is undefined)\n {}\n : // biome-ignore lint/complexity/noBannedTypes: empty object for behaviors without `fields`\n {}\n\ntype ExtractBehaviorFields<B extends Behavior[]> = B extends [\n infer B1,\n ...infer Rest extends Behavior[],\n]\n ? BehaviorFieldsOf<B1> & ExtractBehaviorFields<Rest>\n : // biome-ignore lint/complexity/noBannedTypes: empty object is correct for base case of intersection\n {}\n\n/**\n * Define an entity with full type inference.\n *\n * @example\n * const Article = defineEntity({\n * name: 'article',\n * fields: {\n * title: field.text({ required: true }),\n * featured: field.boolean(),\n * },\n * behaviors: [publishable(), auditable()],\n * })\n *\n * type ArticleDTO = InferEntity<typeof Article>\n * // { id: string; title: string; featured?: boolean | null; status?: ...; createdAt?: ...; }\n */\nexport function defineEntity<\n F extends Record<string, FieldConfig>,\n const B extends Behavior[] = [],\n>(\n definition: EntityDefinition<F> & { behaviors?: B },\n): Entity<{ id: IdField } & ExtractBehaviorFields<B> & F> {\n // Merge behavior fields into main fields\n const behaviorFields: Record<string, FieldConfig> = {}\n\n for (const behavior of definition.behaviors || []) {\n if (behavior.fields) {\n for (const [fieldName, fieldConfig] of Object.entries(\n behavior.fields as Record<string, FieldConfig>,\n )) {\n if (behaviorFields[fieldName]) {\n console.warn(\n `Field '${fieldName}' from behavior '${behavior.name}' conflicts with existing field. Skipping.`,\n )\n continue\n }\n behaviorFields[fieldName] = fieldConfig\n }\n }\n }\n\n // Build complete field set\n // Order: id (always first) → behavior fields → user-defined fields\n const allFields: Record<string, FieldConfig> = {\n id: field.id(), // Every entity gets an ID\n ...behaviorFields,\n ...definition.fields,\n }\n\n // Auto-infer translatable slugs: if a slug field's source field is translatable,\n // the slug should be translatable too (each locale gets its own URL-safe slug).\n for (const [, fieldConfig] of Object.entries(allFields)) {\n if (\n fieldConfig.type === 'slug' &&\n !fieldConfig.translatable &&\n allFields[fieldConfig.from]?.translatable\n ) {\n fieldConfig.translatable = true\n }\n }\n\n // The cast through `unknown` is safe: runtime merge order (id + behaviors + user fields)\n // exactly mirrors the type-level intersection. TypeScript cannot verify imperative\n // Object.entries() loops produce the same result as a type-level intersection,\n // so we cast at this single auditable boundary.\n return {\n ...definition,\n allFields,\n } as unknown as Entity<{ id: IdField } & ExtractBehaviorFields<B> & F>\n}\n","/**\n * Error thrown when attempting to delete an entity that is still referenced.\n */\n\nimport type { EntityUsage } from './find-usages.js'\n\nexport class ReferencedEntityError extends Error {\n public readonly entityName: string\n public readonly entityId: string\n public readonly usages: EntityUsage[]\n\n constructor(entityName: string, entityId: string, usages: EntityUsage[]) {\n const count = usages.length\n super(\n `Cannot delete ${entityName} '${entityId}': referenced by ${count} other entit${count === 1 ? 'y' : 'ies'}`,\n )\n this.name = 'ReferencedEntityError'\n this.entityName = entityName\n this.entityId = entityId\n this.usages = usages\n }\n}\n","/**\n * entity_refs — Universal reference tracking table.\n *\n * Every reference between entities (field.media(), field.reference(), block media fields)\n * gets a row here at write time. This enables:\n * - Instant \"where is this used?\" queries (one indexed lookup)\n * - Universal delete protection (can't delete referenced entities)\n * - Correct results (schema-aware, no LIKE text search)\n */\n\nimport { index, pgTable, unique, uuid, varchar } from 'drizzle-orm/pg-core'\n\nexport const entityRefs = pgTable(\n 'entity_refs',\n {\n sourceEntity: varchar('source_entity', { length: 100 }).notNull(),\n sourceId: uuid('source_id').notNull(),\n sourceField: varchar('source_field', { length: 100 }).notNull(),\n targetEntity: varchar('target_entity', { length: 100 }).notNull(),\n targetId: uuid('target_id').notNull(),\n },\n (t) => [\n unique('uq_entity_refs').on(\n t.sourceEntity,\n t.sourceId,\n t.sourceField,\n t.targetEntity,\n t.targetId,\n ),\n index('idx_entity_refs_target').on(t.targetEntity, t.targetId),\n index('idx_entity_refs_source').on(t.sourceEntity, t.sourceId),\n ],\n)\n","/**\n * Schema generator\n * Converts entity definitions to Drizzle schemas.\n * Every field gets its own Postgres column — no JSONB blobs.\n */\n\nimport { getTableColumns } from 'drizzle-orm'\nimport type { PgColumnBuilderBase, PgTable } from 'drizzle-orm/pg-core'\nimport {\n boolean,\n doublePrecision,\n index,\n integer,\n jsonb,\n pgTable,\n text,\n timestamp,\n unique,\n uuid,\n varchar,\n} from 'drizzle-orm/pg-core'\nimport type { Entity } from './define-entity.js'\nimport type { FieldConfig } from './fields/base.js'\nimport { entityRefs } from './refs/schema.js'\n\n/**\n * Convert a field config to a Drizzle column.\n *\n * @param nullable - When true, skips notNull and default constraints.\n * Used for translation table columns (translations are partial overrides).\n */\nexport function fieldToColumn(\n fieldName: string,\n fieldConfig: FieldConfig,\n options?: { nullable?: boolean },\n): PgColumnBuilderBase {\n const nullable = options?.nullable ?? false\n\n switch (fieldConfig.type) {\n case 'id':\n return uuid(fieldName).primaryKey().defaultRandom()\n\n case 'text':\n if (fieldConfig.maxLength && fieldConfig.maxLength <= 255) {\n let column = varchar(fieldName, { length: fieldConfig.maxLength })\n if (fieldConfig.unique) column = column.unique()\n if (!nullable && fieldConfig.required) column = column.notNull()\n if (!nullable && fieldConfig.default !== undefined)\n column = column.default(fieldConfig.default as string)\n return column\n } else {\n let column = text(fieldName)\n if (fieldConfig.unique) column = column.unique()\n if (!nullable && fieldConfig.required) column = column.notNull()\n if (!nullable && fieldConfig.default !== undefined)\n column = column.default(fieldConfig.default as string)\n return column\n }\n\n case 'number': {\n let numColumn = fieldConfig.integer ? integer(fieldName) : doublePrecision(fieldName)\n if (!nullable && fieldConfig.required) numColumn = numColumn.notNull()\n if (!nullable && fieldConfig.default !== undefined)\n numColumn = numColumn.default(fieldConfig.default as number)\n return numColumn\n }\n\n case 'boolean': {\n let boolColumn = boolean(fieldName)\n if (!nullable && fieldConfig.required) boolColumn = boolColumn.notNull()\n if (!nullable) {\n const boolDefault =\n fieldConfig.default !== undefined ? (fieldConfig.default as boolean) : false\n boolColumn = boolColumn.default(boolDefault)\n }\n return boolColumn\n }\n\n case 'date': {\n let dateColumn = timestamp(fieldName, { withTimezone: true })\n if (!nullable && fieldConfig.required) dateColumn = dateColumn.notNull()\n if (!nullable && fieldConfig.default !== undefined)\n dateColumn = dateColumn.default(fieldConfig.default as Date)\n return dateColumn\n }\n\n case 'select': {\n let selectColumn = varchar(fieldName, { length: 100 })\n if (!nullable && fieldConfig.required) selectColumn = selectColumn.notNull()\n if (!nullable && fieldConfig.default !== undefined)\n selectColumn = selectColumn.default(fieldConfig.default as string)\n return selectColumn\n }\n\n case 'reference': {\n if (fieldConfig.cardinality === 'many') {\n return uuid(fieldName).array()\n }\n let refColumn = uuid(fieldName)\n if (!nullable && fieldConfig.required) refColumn = refColumn.notNull()\n return refColumn\n }\n\n case 'media': {\n let mediaColumn = uuid(fieldName)\n if (!nullable && fieldConfig.required) mediaColumn = mediaColumn.notNull()\n return mediaColumn\n }\n\n case 'slug': {\n let slugColumn = varchar(fieldName, { length: 255 })\n // For translation tables (nullable=true), skip per-column unique —\n // translatable slugs use composite UNIQUE(slug, locale) at table level instead.\n if (fieldConfig.unique && !nullable) slugColumn = slugColumn.unique()\n if (!nullable && fieldConfig.required) slugColumn = slugColumn.notNull()\n return slugColumn\n }\n\n case 'richtext': {\n // TipTap HTML strings stored as text\n let rtColumn = text(fieldName)\n if (!nullable && fieldConfig.required) rtColumn = rtColumn.notNull()\n return rtColumn\n }\n\n case 'json':\n return jsonb(fieldName)\n\n default:\n return text(fieldName)\n }\n}\n\n/**\n * Generate a runtime Drizzle schema from an entity definition.\n * Every field becomes its own column — no JSONB.\n */\nexport function generateSchema(entity: Entity) {\n const tableName = entity.name\n const columns: Record<string, PgColumnBuilderBase> = {}\n\n for (const [fieldName, fieldConfig] of Object.entries(entity.allFields)) {\n if (fieldConfig.type === 'blocks') continue // stored in layout table\n columns[fieldName] = fieldToColumn(fieldName, fieldConfig)\n }\n\n if (entity.scope === 'team' || entity.scope === 'user') {\n columns._scopeId = uuid('_scope_id')\n }\n\n // Collect fields that need indexes (skip id/unique — they already have btree indexes)\n const indexedFields = Object.entries(entity.allFields).filter(\n ([_, config]) =>\n config.type !== 'blocks' && config.type !== 'id' && config.indexed && !config.unique,\n )\n\n const isPublishableEntity = entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n\n if (indexedFields.length === 0 && !isPublishableEntity) {\n return pgTable(tableName, columns)\n }\n\n return pgTable(tableName, columns, (table) => {\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns from pgTable callback\n const constraints: Record<string, any> = {}\n\n for (const [fieldName] of indexedFields) {\n constraints[`idx_${tableName}_${fieldName}`] = index(`idx_${tableName}_${fieldName}`).on(\n table[fieldName],\n )\n }\n\n // Composite index for publishable entities: status + createdAt (most common list query)\n if (isPublishableEntity && table.status && table.createdAt) {\n constraints[`idx_${tableName}_status_created`] = index(`idx_${tableName}_status_created`).on(\n table.status,\n table.createdAt,\n )\n }\n\n return constraints\n })\n}\n\n/**\n * Generate runtime translation table schema.\n * Each translatable field gets its own nullable column.\n *\n * When `parent` is provided, `entityId` gets a `.references()` FK — used\n * by `lumi migrate` so generated SQL includes `REFERENCES parent(id) ON\n * DELETE CASCADE`. Callers that only need the table shape for query\n * building (core/app.ts, content/plugin.ts) can omit `parent`.\n */\nexport function generateTranslationSchema(entity: Entity, parent?: PgTable) {\n const translatableFields = Object.entries(entity.allFields).filter(\n ([_, config]) => config.translatable,\n )\n\n if (translatableFields.length === 0) return null\n\n const tableName = `${entity.name}_translations`\n const entityIdCol = parent\n ? uuid('entity_id')\n .notNull()\n .references(() => getTableColumns(parent).id, { onDelete: 'cascade' })\n : uuid('entity_id').notNull()\n const columns: Record<string, PgColumnBuilderBase> = {\n id: uuid('id').primaryKey().defaultRandom(),\n entityId: entityIdCol,\n locale: varchar('locale', { length: 10 }).notNull(),\n }\n\n const hasTranslatableSlug = translatableFields.some(([_, config]) => config.type === 'slug')\n\n for (const [fieldName, fieldConfig] of translatableFields) {\n columns[fieldName] = fieldToColumn(fieldName, fieldConfig, { nullable: true })\n }\n\n return pgTable(tableName, columns, (table) => ({\n uniqueEntityLocale: unique().on(table.entityId, table.locale),\n // Per-locale slug uniqueness: no two entities can share the same slug in the same locale\n ...(hasTranslatableSlug && table.slug\n ? { uniqueSlugLocale: unique().on(table.slug, table.locale) }\n : {}),\n }))\n}\n\n/**\n * Check if an entity has any blocks fields\n */\nexport function hasBlocksFields(entity: Entity): boolean {\n return Object.values(entity.allFields).some((f) => f.type === 'blocks')\n}\n\n/**\n * Generate layout table schema for entities with blocks fields.\n * Stores block instances in a separate table with ordering.\n *\n * When blocks field has localized: false (default), locale is NULL (shared layout).\n * When blocks field has localized: true, locale is set per-locale.\n *\n * `parent` is optional — when provided, emits a `.references()` FK (for\n * migration generation); query-runtime callers can omit it.\n */\nexport function generateLayoutSchema(entity: Entity, parent?: PgTable) {\n if (!hasBlocksFields(entity)) return null\n\n const tableName = `${entity.name}_layout`\n const entityIdCol = parent\n ? uuid('entity_id')\n .notNull()\n .references(() => getTableColumns(parent).id, { onDelete: 'cascade' })\n : uuid('entity_id').notNull()\n\n return pgTable(\n tableName,\n {\n id: uuid('id').primaryKey().defaultRandom(),\n entityId: entityIdCol,\n fieldName: varchar('field_name', { length: 100 }).notNull(),\n blockType: varchar('block_type', { length: 100 }).notNull(),\n sortOrder: integer('sort_order').notNull().default(0),\n data: jsonb('data'),\n locale: varchar('locale', { length: 10 }),\n },\n (table) => ({\n // Covers: WHERE entity_id IN (...) AND locale IS NULL ORDER BY sort_order\n idx_entity_locale_sort: index(`idx_${tableName}_entity_locale_sort`).on(\n table.entityId,\n table.locale,\n table.sortOrder,\n ),\n }),\n )\n}\n\n/**\n * Check if an entity has the versionable behavior\n */\nexport function isVersionable(entity: Entity): boolean {\n return entity.behaviors?.some((b) => b.name === 'versionable') ?? false\n}\n\n/**\n * Check if an entity needs a per-locale publish status table.\n * Requires both publishable() behavior AND at least one translatable field.\n */\nexport function needsLocaleStatus(entity: Entity): boolean {\n const isPublishable = entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n if (!isPublishable) return false\n const allFields = entity.allFields as Record<string, { translatable?: boolean }>\n return Object.values(allFields).some((f) => f.translatable)\n}\n\n/**\n * Check if an entity is publishable (has publishable() behavior).\n */\nexport function isPublishable(entity: Entity): boolean {\n return entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n}\n\n// ---------------------------------------------------------------------------\n// Runtime generators for infrastructure tables attached to an entity.\n//\n// Each one takes the parent entity's runtime `pgTable` so the\n// `.references()` FK is wired. These replace the old `generate*Code()`\n// string helpers that produced source lines to be concatenated into\n// `generated/schema.ts`.\n// ---------------------------------------------------------------------------\n\n/** Per-locale publish status table for publishable + translatable entities. */\nexport function generateLocaleStatusSchema(entity: Entity, parent: PgTable) {\n const tableName = `${entity.name}_locale_status`\n return pgTable(\n tableName,\n {\n id: uuid('id').primaryKey().defaultRandom(),\n entityId: uuid('entity_id')\n .notNull()\n .references(() => getTableColumns(parent).id, { onDelete: 'cascade' }),\n locale: varchar('locale', { length: 10 }).notNull(),\n status: varchar('status', { length: 20 }).notNull().default('draft'),\n publishedAt: timestamp('published_at', { withTimezone: true }),\n },\n (table) => ({\n uniqueEntityLocale: unique().on(table.entityId, table.locale),\n }),\n )\n}\n\n/** Drafts overlay table for publishable entities. */\nexport function generateDraftsSchema(entity: Entity, parent: PgTable) {\n const tableName = `${entity.name}_drafts`\n return pgTable(\n tableName,\n {\n id: uuid('id').primaryKey().defaultRandom(),\n entityId: uuid('entity_id')\n .notNull()\n .references(() => getTableColumns(parent).id, { onDelete: 'cascade' }),\n locale: varchar('locale', { length: 10 }).notNull().default('_'),\n data: jsonb('data').notNull(),\n createdBy: varchar('created_by', { length: 255 }).notNull(),\n createdByName: varchar('created_by_name', { length: 255 }),\n createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),\n },\n (table) => ({\n uniqueEntityLocale: unique().on(table.entityId, table.locale),\n }),\n )\n}\n\n/** Versions history table for versionable entities. */\nexport function generateVersionsSchema(entity: Entity, parent: PgTable) {\n const tableName = `${entity.name}_versions`\n return pgTable(\n tableName,\n {\n id: uuid('id').primaryKey().defaultRandom(),\n entityId: uuid('entity_id')\n .notNull()\n .references(() => getTableColumns(parent).id, { onDelete: 'cascade' }),\n version: integer('version').notNull(),\n locale: varchar('locale', { length: 10 }).notNull().default('_'),\n data: jsonb('data').notNull(),\n delta: jsonb('delta'),\n status: varchar('status', { length: 20 }),\n createdBy: varchar('created_by', { length: 255 }),\n createdByName: varchar('created_by_name', { length: 255 }),\n createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n isAutosave: boolean('is_autosave').notNull().default(false),\n },\n (table) => ({\n uniqueEntityVersionLocale: unique().on(table.entityId, table.version, table.locale),\n }),\n )\n}\n\n/**\n * Shared content locks table — one per project, independent of any entity.\n * Included exactly once in `buildRuntimeSchema()` when any entity is publishable.\n */\nexport function generateContentLocksSchema() {\n return pgTable(\n 'toolkit_content_locks',\n {\n id: uuid('id').primaryKey().defaultRandom(),\n entityType: varchar('entity_type', { length: 100 }).notNull(),\n entityId: varchar('entity_id', { length: 255 }).notNull(),\n locale: varchar('locale', { length: 10 }).notNull().default('_'),\n lockedBy: varchar('locked_by', { length: 255 }).notNull(),\n lockedByName: varchar('locked_by_name', { length: 255 }),\n lockedAt: timestamp('locked_at', { withTimezone: true }).notNull().defaultNow(),\n expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),\n },\n (table) => ({\n uniqueEntityLock: unique().on(table.entityType, table.entityId, table.locale),\n }),\n )\n}\n\n/**\n * Check if an entity has blocks fields with translatable block content (non-localized mode).\n */\nexport function hasTranslatableBlocks(entity: Entity): boolean {\n const allFields = entity.allFields as Record<\n string,\n {\n type: string\n localized?: boolean\n blocks?: Array<{ fields: Record<string, { translatable?: boolean }> }>\n }\n >\n return Object.values(allFields).some((f) => {\n if (f.type !== 'blocks') return false\n if (f.localized) return false\n return f.blocks?.some((block) => Object.values(block.fields).some((bf) => bf.translatable))\n })\n}\n\n/**\n * Generate layout translation table for non-localized blocks with translatable fields.\n *\n * `layoutParent` is optional — when provided, emits a `.references()` FK\n * pointing at the corresponding `{entity}_layout` table (for migration\n * generation).\n */\nexport function generateLayoutTranslationSchema(entity: Entity, layoutParent?: PgTable) {\n const blocksFields = Object.values(entity.allFields).filter(\n (f) => f.type === 'blocks' && !('localized' in f && f.localized),\n )\n\n if (blocksFields.length === 0) return null\n\n const hasTranslatableBlockFields = blocksFields.some((bf) => {\n if (bf.type !== 'blocks') return false\n return bf.blocks.some((block) => Object.values(block.fields).some((f) => f.translatable))\n })\n\n if (!hasTranslatableBlockFields) return null\n\n const tableName = `${entity.name}_layout_translations`\n const layoutIdCol = layoutParent\n ? uuid('layout_id')\n .notNull()\n .references(() => getTableColumns(layoutParent).id, { onDelete: 'cascade' })\n : uuid('layout_id').notNull()\n\n return pgTable(\n tableName,\n {\n id: uuid('id').primaryKey().defaultRandom(),\n layoutId: layoutIdCol,\n locale: varchar('locale', { length: 10 }).notNull(),\n fields: jsonb('fields').notNull(),\n },\n (table) => ({\n uniqueLayoutLocale: unique().on(table.layoutId, table.locale),\n }),\n )\n}\n\n/**\n * Build the full runtime schema map for a list of entities.\n *\n * Returns a `Record<string, PgTable>` suitable for feeding into\n * `drizzle-kit/api`'s `generateDrizzleJson()`. Includes every table\n * that would have been emitted into `generated/schema.ts` by the old\n * codegen pipeline: main entity, translations, layout, layout\n * translations, versions, drafts, locale status, and the shared\n * content locks table.\n *\n * This is the single entry point used by `lumi migrate` — plugin\n * internal tables are added separately by reading each plugin's\n * `tables` field.\n */\nexport function buildEntitySchemaMap(entities: Entity[]): Record<string, PgTable> {\n const schema: Record<string, PgTable> = {}\n let hasAnyPublishable = false\n\n for (const entity of entities) {\n const main = generateSchema(entity)\n schema[entity.name] = main\n\n const translations = generateTranslationSchema(entity, main)\n if (translations) schema[`${entity.name}_translations`] = translations\n\n if (hasBlocksFields(entity)) {\n const layout = generateLayoutSchema(entity, main)\n if (layout) {\n schema[`${entity.name}_layout`] = layout\n const layoutTrans = generateLayoutTranslationSchema(entity, layout)\n if (layoutTrans) schema[`${entity.name}_layout_translations`] = layoutTrans\n }\n }\n\n if (isVersionable(entity)) {\n schema[`${entity.name}_versions`] = generateVersionsSchema(entity, main)\n }\n if (needsLocaleStatus(entity)) {\n schema[`${entity.name}_locale_status`] = generateLocaleStatusSchema(entity, main)\n }\n if (isPublishable(entity)) {\n schema[`${entity.name}_drafts`] = generateDraftsSchema(entity, main)\n hasAnyPublishable = true\n }\n }\n\n if (hasAnyPublishable) {\n schema.toolkit_content_locks = generateContentLocksSchema()\n }\n\n // Universal reference tracking table — used by AdminClient.syncRefs()\n // for delete protection and cross-entity usage queries.\n schema.entity_refs = entityRefs\n\n return schema\n}\n","/**\n * Shared entity data operations.\n *\n * Extracted from AdminClient and QueryClient to eliminate duplication.\n * These are standalone functions that take an EntityContext — both clients\n * satisfy this interface via `this`.\n */\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { and, eq, inArray, isNull, or, type SQL } from 'drizzle-orm'\nimport type { PgTableWithColumns } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { Entity } from '../define-entity.js'\nimport type { FieldConfig } from '../fields/base.js'\n\n// ---------------------------------------------------------------------------\n// Context interface — satisfied by both AdminClient and QueryClient via `this`\n// ---------------------------------------------------------------------------\n\n/**\n * Security context resolved per-request. Provided by the consumer\n * (e.g., via React.cache() in Next.js, or runAsCli for CLI).\n * The entity package has zero knowledge of how this is resolved.\n */\nexport interface SecurityContext {\n user: { id: string; groups: string[]; name?: string; email?: string }\n checker: (role: string, resource: string, action: string) => boolean\n scope?: { type: string; id: string }\n}\n\n/**\n * Function that resolves the current request's security context.\n * Injected at construction time — the entity package never imports\n * @murumets-ee/core or any framework-specific code.\n *\n * Returns undefined only when intentionally skipping enforcement\n * (should not happen in production — throw ForbiddenError instead).\n */\nexport type ContextResolver = () =>\n | SecurityContext\n | undefined\n | Promise<SecurityContext | undefined>\n\nexport interface EntityContext {\n entity: Entity\n db: PostgresJsDatabase\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns require PgTableWithColumns<any>\n table: PgTableWithColumns<any>\n /** Resolves the current request's security context. */\n resolveContext?: ContextResolver\n}\n\n// ---------------------------------------------------------------------------\n// Field accessors\n// ---------------------------------------------------------------------------\n\n/** Get the entity's full field map with proper typing. Eliminates repeated casts. */\nexport function getAllFields(ctx: EntityContext): Record<string, FieldConfig> {\n return ctx.entity.allFields as Record<string, FieldConfig>\n}\n\n/** Get all blocks field definitions for this entity. */\nexport function getBlocksFields(ctx: EntityContext): Array<{ name: string; config: FieldConfig }> {\n return Object.entries(getAllFields(ctx))\n .filter(([_, config]) => config.type === 'blocks')\n .map(([name, config]) => ({ name, config }))\n}\n\n// ---------------------------------------------------------------------------\n// Translation merging\n// ---------------------------------------------------------------------------\n\n/**\n * Merge translations into entities for the specified locale.\n * Reads translatable field values from real columns on the translation row.\n */\nexport async function mergeTranslations<T extends Record<string, unknown>>(\n ctx: EntityContext,\n entities: T[],\n locale: string,\n): Promise<T[]> {\n if (!entities.length) return entities\n\n const translationTableName = `${ctx.entity.name}_translations`\n const translationTable = schemaRegistry.get(translationTableName)\n\n if (!translationTable) return entities\n\n const entityIds = entities.map((e) => e.id)\n\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(and(inArray(translationTable.entityId, entityIds), eq(translationTable.locale, locale)))\n\n // Get translatable field names\n const translatableFields = Object.entries(getAllFields(ctx))\n .filter(([_, config]) => config.translatable)\n .map(([name]) => name)\n\n const translationMap = new Map<unknown, Record<string, unknown>>()\n for (const translation of translations) {\n const translatedValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n const value = (translation as Record<string, unknown>)[fieldName]\n if (value !== undefined && value !== null) {\n translatedValues[fieldName] = value\n }\n }\n translationMap.set(translation.entityId, translatedValues)\n }\n\n return entities.map((entity) => {\n const translatedFields = translationMap.get(entity.id)\n if (!translatedFields) return entity\n return { ...entity, ...translatedFields }\n })\n}\n\n// ---------------------------------------------------------------------------\n// Block loading\n// ---------------------------------------------------------------------------\n\nexport interface LoadBlocksOptions {\n defaultLocale?: string\n /**\n * When true (admin editing), clears untranslated translatable block fields\n * to empty string — signals incomplete translation in the editing UI.\n * When false (visitor-facing), preserves base data as fallback.\n */\n strictTranslations?: boolean\n}\n\n/**\n * Load blocks for one or more entities from the layout table.\n *\n * Handles both block translation modes:\n * - Shared layout (localized: false): loads locale=NULL rows, merges translations\n * - Per-locale layout (localized: true): loads rows matching the provided locale only\n */\nexport async function loadBlocks(\n ctx: EntityContext,\n entityIds: string[],\n locale?: string,\n options?: LoadBlocksOptions,\n): Promise<Map<string, Record<string, unknown[]>>> {\n const blocksFields = getBlocksFields(ctx)\n if (blocksFields.length === 0 || entityIds.length === 0) {\n return new Map()\n }\n\n const layoutTable = schemaRegistry.get(`${ctx.entity.name}_layout`)\n if (!layoutTable) return new Map()\n\n // Determine block field modes\n const sharedFields = blocksFields.filter(\n ({ config }) => !('localized' in config && config.localized),\n )\n const localizedFields = blocksFields.filter(\n ({ config }) => 'localized' in config && config.localized,\n )\n\n const result = new Map<string, Record<string, unknown[]>>()\n\n // ---- Shared blocks: locale IS NULL, translations from layout_translations ----\n if (sharedFields.length > 0) {\n const rows = await ctx.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), isNull(layoutTable.locale)))\n .orderBy(layoutTable.sortOrder)\n\n // Load translations if locale provided\n let blockTransMap: Map<string, Record<string, unknown>> | undefined\n if (locale && rows.length > 0) {\n const layoutTransTable = schemaRegistry.get(`${ctx.entity.name}_layout_translations`)\n if (layoutTransTable) {\n const layoutIds = rows.map((r) => r.id as string)\n const translations = await ctx.db\n .select()\n .from(layoutTransTable)\n .where(\n and(inArray(layoutTransTable.layoutId, layoutIds), eq(layoutTransTable.locale, locale)),\n )\n\n blockTransMap = new Map()\n for (const t of translations) {\n blockTransMap.set(t.layoutId as string, (t.fields as Record<string, unknown>) ?? {})\n }\n }\n }\n\n // Build block definition lookup for translatable field clearing (strict mode only)\n let blockDefMap: Map<string, Record<string, FieldConfig>> | undefined\n if (options?.strictTranslations) {\n blockDefMap = new Map()\n for (const { config } of sharedFields) {\n if (config.type !== 'blocks') continue\n for (const def of config.blocks) {\n blockDefMap.set(def.slug, def.fields)\n }\n }\n }\n\n for (const row of rows) {\n const eid = row.entityId as string\n const fname = row.fieldName as string\n\n let entityBlocks = result.get(eid)\n if (!entityBlocks) {\n entityBlocks = {}\n result.set(eid, entityBlocks)\n }\n if (!entityBlocks[fname]) entityBlocks[fname] = []\n\n const blockData = (row.data as Record<string, unknown>) ?? {}\n const translatedFields = blockTransMap?.get(row.id as string)\n\n // Build the block object\n const block: Record<string, unknown> = {\n _block: row.blockType,\n _id: row.id,\n ...blockData,\n ...(translatedFields ?? {}),\n }\n\n // Strict mode: clear untranslated translatable fields when loading for non-default locale\n if (locale && blockDefMap) {\n const blockType = row.blockType as string\n const fieldDefs = blockDefMap.get(blockType)\n if (fieldDefs) {\n for (const [fieldName, fieldConfig] of Object.entries(fieldDefs)) {\n if (fieldConfig.translatable && !translatedFields?.[fieldName]) {\n block[fieldName] = ''\n }\n }\n }\n }\n\n entityBlocks[fname].push(block)\n }\n }\n\n // ---- Localized blocks: filter by locale column ----\n // NULL rows (from initial create) only fall back for the default locale.\n // Non-default locales without locale-specific rows get an empty array.\n if (localizedFields.length > 0) {\n const isDefault = !locale || locale === options?.defaultLocale\n let localeCondition: SQL\n if (locale) {\n if (isDefault) {\n // Both args defined → drizzle's `or` always returns SQL; fall back defensively\n localeCondition =\n or(eq(layoutTable.locale, locale), isNull(layoutTable.locale)) ??\n eq(layoutTable.locale, locale)\n } else {\n localeCondition = eq(layoutTable.locale, locale)\n }\n } else {\n localeCondition = isNull(layoutTable.locale)\n }\n\n const rows = await ctx.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), localeCondition))\n .orderBy(layoutTable.sortOrder)\n\n // Group by entityId + fieldName, prefer locale-specific rows over NULL\n const grouped = new Map<string, { localeRows: typeof rows; nullRows: typeof rows }>()\n for (const row of rows) {\n const key = `${row.entityId}::${row.fieldName}`\n let group = grouped.get(key)\n if (!group) {\n group = { localeRows: [], nullRows: [] }\n grouped.set(key, group)\n }\n if (row.locale) {\n group.localeRows.push(row)\n } else {\n group.nullRows.push(row)\n }\n }\n\n for (const [, { localeRows, nullRows }] of grouped) {\n // Use locale-specific rows if available, otherwise fall back to NULL\n const effective = localeRows.length > 0 ? localeRows : nullRows\n\n for (const row of effective) {\n const eid = row.entityId as string\n const fname = row.fieldName as string\n\n let entityBlocks = result.get(eid)\n if (!entityBlocks) {\n entityBlocks = {}\n result.set(eid, entityBlocks)\n }\n if (!entityBlocks[fname]) entityBlocks[fname] = []\n\n const blockData = (row.data as Record<string, unknown>) ?? {}\n\n entityBlocks[fname].push({\n _block: row.blockType,\n _id: row.id,\n ...blockData,\n })\n }\n }\n }\n\n return result\n}\n\n/**\n * Attach loaded blocks to shaped DTOs.\n */\nexport function attachBlocks(\n ctx: EntityContext,\n entities: Record<string, unknown>[],\n blocksMap: Map<string, Record<string, unknown[]>>,\n): void {\n const blocksFields = getBlocksFields(ctx)\n if (blocksFields.length === 0) return\n\n for (const entity of entities) {\n const eid = entity.id as string\n const entityBlocks = blocksMap.get(eid) ?? {}\n\n for (const { name } of blocksFields) {\n entity[name] = entityBlocks[name] ?? []\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Count cache key\n// ---------------------------------------------------------------------------\n\n/**\n * Build a cache key for a count query.\n * @param prefix - Optional namespace prefix (e.g. 'query' for QueryClient)\n * @param scopeId - Optional scope ID for multi-tenant isolation\n */\nexport function buildCountCacheKey(\n entityName: string,\n where?: SQL,\n prefix?: string,\n scopeId?: string,\n): string {\n let base = prefix ? `${prefix}:${entityName}` : entityName\n if (scopeId) base = `${base}@${scopeId}`\n if (!where) return base\n // Use SQL's toString() representation as a stable key.\n // This is a display string, not executed — safe for cache keying.\n return `${base}:${String(where)}`\n}\n\n// ---------------------------------------------------------------------------\n// ForbiddenError\n// ---------------------------------------------------------------------------\n\n/**\n * Thrown when a user lacks permission for an entity operation.\n * Consumers (api-handler) catch this and return HTTP 403.\n */\nexport class ForbiddenError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'ForbiddenError'\n }\n}\n\n// ---------------------------------------------------------------------------\n// Context helpers — dynamic imports to avoid entity → core circular dep\n// ---------------------------------------------------------------------------\n\n// ---------------------------------------------------------------------------\n// Context resolution — framework-agnostic\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve the security context from the resolver on EntityContext.\n * Throws ForbiddenError if no resolver is configured or if it returns undefined.\n */\nasync function resolveSecurityContext(\n resolver: ContextResolver | undefined,\n): Promise<SecurityContext> {\n if (!resolver) {\n throw new ForbiddenError(\n 'No context resolver configured on AdminClient/QueryClient. ' +\n 'Use createAdminClient() from @murumets-ee/core/clients, or pass a contextResolver in config.',\n )\n }\n\n const ctx = await resolver()\n\n if (!ctx) {\n throw new ForbiddenError(\n 'Context resolver returned no context. ' +\n 'Ensure auth is configured in your request handler, or use runAsCli() for CLI scripts.',\n )\n }\n\n return ctx\n}\n\n// ---------------------------------------------------------------------------\n// Permission enforcement\n// ---------------------------------------------------------------------------\n\n/**\n * Assert the current user has permission for the given action on the entity.\n * Uses the PermissionChecker from the resolved SecurityContext.\n */\nexport async function assertEntityAccess(\n entity: Entity,\n resolver: ContextResolver | undefined,\n action: 'view' | 'create' | 'update' | 'delete',\n): Promise<void> {\n const ctx = await resolveSecurityContext(resolver)\n\n const role = ctx.user.groups[0]\n if (!role) {\n throw new ForbiddenError(`User '${ctx.user.id}' has no role assigned.`)\n }\n\n if (!ctx.checker(role, entity.name, action)) {\n throw new ForbiddenError(`Forbidden: role '${role}' cannot ${action} '${entity.name}'`)\n }\n}\n\n// ---------------------------------------------------------------------------\n// Scope filtering\n// ---------------------------------------------------------------------------\n\n/**\n * Get the current scope ID for a scoped entity.\n * Returns undefined for global entities.\n */\nexport async function getScopeId(\n entity: Entity,\n resolver: ContextResolver | undefined,\n): Promise<string | undefined> {\n if (!entity.scope || entity.scope === 'global') return undefined\n\n const ctx = await resolveSecurityContext(resolver)\n return ctx.scope?.id\n}\n\n/**\n * Build a WHERE condition for scope filtering.\n */\nexport function buildScopeCondition(ctx: EntityContext, scopeId: string): SQL {\n return eq(ctx.table._scopeId, scopeId)\n}\n\n/**\n * Require scope for a scoped entity. Throws if no scope is in context.\n * Returns undefined for global entities.\n */\nexport async function requireScopeForEntity(\n entity: Entity,\n resolver: ContextResolver | undefined,\n): Promise<string | undefined> {\n if (!entity.scope || entity.scope === 'global') return undefined\n\n const ctx = await resolveSecurityContext(resolver)\n const scopeId = ctx.scope?.id\n\n if (!scopeId) {\n throw new ForbiddenError(\n `Entity '${entity.name}' requires scope '${entity.scope}' but no scope is set in context.`,\n )\n }\n\n return scopeId\n}\n\n// ---------------------------------------------------------------------------\n// Combined auth context resolution (single resolver call)\n// ---------------------------------------------------------------------------\n\n/** Behavior context shape — duplicated locally to avoid importing from behaviors package. */\ninterface BehaviorCtx {\n user?: { id: string; name?: string; email?: string }\n}\n\nexport interface ResolvedAuthContext {\n /** The full SecurityContext returned by the resolver. */\n security: SecurityContext\n /** Scope ID for scoped entities; undefined for global. */\n scopeId: string | undefined\n /** BehaviorContext to thread into lifecycle hooks. */\n behaviorCtx: BehaviorCtx\n}\n\n/**\n * Resolve security context, check permission, derive scope ID, and build\n * BehaviorContext — all from a single resolver invocation.\n *\n * Use instead of calling `assertEntityAccess` + `getScopeId`/`requireScopeForEntity`\n * + manually building a behavior context. Reduces 2-3 resolver calls per write\n * to one, while keeping the granular helpers available for special cases\n * (e.g. cascade delete, which checks permission on a different entity).\n *\n * @param strictScope When true and entity is scoped without a scope in context,\n * throws ForbiddenError. Set on write paths; leave false for\n * reads (callers receive scopeId=undefined and skip filtering).\n */\nexport async function resolveAuthContext(\n entity: Entity,\n resolver: ContextResolver | undefined,\n action: 'view' | 'create' | 'update' | 'delete',\n options?: { strictScope?: boolean },\n): Promise<ResolvedAuthContext> {\n const security = await resolveSecurityContext(resolver)\n\n // Permission check — same logic as assertEntityAccess()\n const role = security.user.groups[0]\n if (!role) {\n throw new ForbiddenError(`User '${security.user.id}' has no role assigned.`)\n }\n if (!security.checker(role, entity.name, action)) {\n throw new ForbiddenError(`Forbidden: role '${role}' cannot ${action} '${entity.name}'`)\n }\n\n // Scope resolution — same logic as getScopeId()/requireScopeForEntity()\n let scopeId: string | undefined\n if (entity.scope && entity.scope !== 'global') {\n scopeId = security.scope?.id\n if (!scopeId && options?.strictScope) {\n throw new ForbiddenError(\n `Entity '${entity.name}' requires scope '${entity.scope}' but no scope is set in context.`,\n )\n }\n }\n\n const behaviorCtx: BehaviorCtx = {\n user: { id: security.user.id, name: security.user.name, email: security.user.email },\n }\n\n return { security, scopeId, behaviorCtx }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/fields/builders.ts","../src/behaviors/auditable.ts","../src/behaviors/hierarchical.ts","../src/behaviors/publishable.ts","../src/behaviors/revisionable.ts","../src/behaviors/sluggable.ts","../src/behaviors/timestamped.ts","../src/behaviors/index.ts","../src/count-cache.ts","../src/count-estimate.ts","../src/cursor.ts","../src/define-entity.ts","../src/refs/errors.ts","../src/refs/schema.ts","../src/schema-generator.ts","../src/shared/entity-data-ops.ts"],"sourcesContent":["/**\n * Fluent API for building field definitions\n * Provides type-safe field builders with sensible defaults.\n *\n * Each builder uses a `const` generic parameter on the config to preserve\n * literal types (e.g., `required: true` stays `true`, not `boolean`).\n * This enables compile-time type inference in the entity system.\n */\n\nimport type {\n BlockDefinitionRef,\n BlocksField,\n BooleanField,\n DateField,\n IdField,\n JsonField,\n MediaField,\n NumberField,\n ReferenceField,\n RichTextField,\n SelectField,\n SlugField,\n TextField,\n} from './base.js'\n\nexport const field = {\n /**\n * ID field (auto-added to every entity)\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n id: <const C extends Partial<IdField> = {}>(config?: C): IdField & C =>\n ({\n type: 'id',\n required: true,\n indexed: true,\n ...config,\n }) as IdField & C,\n\n /**\n * Text field\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n text: <const C extends Partial<TextField> = {}>(config?: C): TextField & C =>\n ({\n type: 'text',\n ...config,\n }) as TextField & C,\n\n /**\n * Number field\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n number: <const C extends Partial<NumberField> = {}>(config?: C): NumberField & C =>\n ({\n type: 'number',\n ...config,\n }) as NumberField & C,\n\n /**\n * Boolean field\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n boolean: <const C extends Partial<BooleanField> = {}>(config?: C): BooleanField & C =>\n ({\n type: 'boolean',\n default: false,\n ...config,\n }) as BooleanField & C,\n\n /**\n * Date field\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n date: <const C extends Partial<DateField> = {}>(config?: C): DateField & C =>\n ({\n type: 'date',\n ...config,\n }) as DateField & C,\n\n /**\n * Select field (enum)\n * Preserves literal option types for type inference.\n * String options → varchar column, number options → integer column.\n * e.g. field.select({ options: ['news', 'tutorial'] }) infers 'news' | 'tutorial'\n * e.g. field.select({ options: [0, 1, 2, 3] }) infers 0 | 1 | 2 | 3\n */\n select: <\n const O extends readonly string[],\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n const C extends Omit<Partial<SelectField>, 'options'> = {},\n >(\n config: { options: O } & C,\n ): SelectField & { options: O } & C =>\n ({\n type: 'select',\n ...config,\n }) as SelectField & { options: O } & C,\n\n /**\n * Reference field (foreign key to another entity)\n * Preserves literal cardinality to distinguish string vs string[] in inferred types.\n */\n reference: <\n C extends 'one' | 'many' = 'one',\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n const R extends Partial<Omit<ReferenceField, 'entity' | 'cardinality'>> = {},\n >(\n config: { entity: string; cardinality?: C } & R,\n ): ReferenceField & { cardinality: C } & R =>\n ({\n type: 'reference',\n cardinality: 'one' as C,\n onDelete: 'set-null',\n ...config,\n }) as ReferenceField & { cardinality: C } & R,\n\n /**\n * Media field (file upload)\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n media: <const C extends Partial<MediaField> = {}>(config?: C): MediaField & C =>\n ({\n type: 'media',\n ...config,\n }) as MediaField & C,\n\n /**\n * Rich text field (WYSIWYG editor)\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n richtext: <const C extends Partial<RichTextField> = {}>(config?: C): RichTextField & C =>\n ({\n type: 'richtext',\n ...config,\n }) as RichTextField & C,\n\n /**\n * Slug field (URL-safe string, auto-generated from source field)\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n slug: <const C extends Partial<SlugField> = {}>(\n config: Pick<SlugField, 'from'> & C,\n ): SlugField & C =>\n ({\n type: 'slug',\n unique: true,\n indexed: true,\n ...config,\n }) as SlugField & C,\n\n /**\n * JSON field (arbitrary JSON data, stored as JSONB)\n */\n // biome-ignore lint/complexity/noBannedTypes: {} is correct as empty generic default\n json: <const C extends Partial<JsonField> = {}>(config?: C): JsonField & C =>\n ({\n type: 'json',\n ...config,\n }) as JsonField & C,\n\n /**\n * Blocks field — ordered array of typed content blocks\n * Preserves literal block tuple type for discriminated union inference.\n */\n blocks: <const B extends readonly BlockDefinitionRef[]>(config: {\n blocks: B\n min?: number\n max?: number\n localized?: boolean\n }): BlocksField & { blocks: B } =>\n ({\n type: 'blocks' as const,\n ...config,\n }) as BlocksField & { blocks: B },\n}\n","/**\n * Auditable behavior\n * Adds createdBy, updatedBy, createdAt, updatedAt fields + auto-set logic.\n *\n * The user id is read from the BehaviorContext passed in by AdminClient.\n * AdminClient resolves it once per request from its `contextResolver` —\n * the entity package never imports @murumets-ee/core or touches ALS.\n */\n\nimport { field } from '../fields/builders.js'\nimport type { AuditableFields } from '../types/infer.js'\nimport type { Behavior } from './types.js'\n\nexport function auditable(): Behavior<AuditableFields> {\n return {\n name: 'auditable',\n fields: {\n createdBy: field.text(),\n updatedBy: field.text(),\n createdAt: field.date({ indexed: true }),\n updatedAt: field.date({ indexed: true }),\n },\n hooks: {\n beforeCreate: async (data, ctx) => {\n data.createdAt = new Date()\n data.updatedAt = new Date()\n const userId = ctx?.user?.id\n if (userId) {\n data.createdBy = userId\n data.updatedBy = userId\n }\n return data\n },\n beforeUpdate: async (_id, data, ctx) => {\n data.updatedAt = new Date()\n const userId = ctx?.user?.id\n if (userId) {\n data.updatedBy = userId\n }\n return data\n },\n },\n }\n}\n","/**\n * Hierarchical behavior\n * Adds parentId, path, depth fields for materialized path tree structures.\n *\n * Path format: /uuid1/uuid2/uuid3\n * - Root items: /{ownId}\n * - Children: /{rootId}/.../{parentId}/{ownId}\n *\n * The behavior declares fields and sets safe defaults in hooks.\n * Heavy lifting (path computation, circular ref validation, descendant cascading)\n * is done by TaxonomyClient which has DB access.\n */\n\nimport { field } from '../fields/builders.js'\nimport type { HierarchicalFields } from '../types/infer.js'\nimport type { Behavior } from './types.js'\n\nexport interface HierarchicalOptions {\n /**\n * What happens when deleting a node that has children.\n * - 'restrict': Prevent deletion (default)\n * - 'cascade': Delete all descendants\n * - 'reparent': Move children to the deleted node's parent\n */\n onDelete?: 'restrict' | 'cascade' | 'reparent'\n}\n\nexport function hierarchical(_options?: HierarchicalOptions): Behavior<HierarchicalFields> {\n return {\n name: 'hierarchical',\n fields: {\n parentId: field.reference({ entity: '_self', required: false }),\n // path + depth are server-managed: TaxonomyClient computes them from\n // parentId on create and rewrites them in move()/cascade-delete.\n // They are not marked `internal: true` because frontends + tree UIs\n // need them in read responses; instead, the taxonomy admin route\n // boundary strips them from incoming PATCH/POST bodies before they\n // reach AdminClient. See packages/taxonomy/src/admin/routes.ts.\n path: field.text({ indexed: true, maxLength: 2048 }),\n depth: field.number({ integer: true, default: 0, indexed: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n // Set defaults for root-level items.\n // TaxonomyClient overrides these after insert when it has the ID.\n if (!data.parentId) {\n data.depth = 0\n }\n return data\n },\n },\n }\n}\n","/**\n * Publishable behavior\n * Adds status (draft/published) and publishedAt fields + auto-set logic\n */\n\nimport { field } from '../fields/builders.js'\nimport type { PublishableFields } from '../types/infer.js'\nimport type { Behavior } from './types.js'\n\nexport function publishable(): Behavior<PublishableFields> {\n return {\n name: 'publishable',\n fields: {\n status: field.select({\n options: ['draft', 'published'] as const,\n default: 'draft',\n indexed: true,\n }),\n publishedAt: field.date({ indexed: true }),\n },\n hooks: {\n beforeUpdate: async (_id, data) => {\n // Auto-set publishedAt when status changes to published (if not already set)\n if (data.status === 'published' && !data.publishedAt) {\n data.publishedAt = new Date()\n }\n return data\n },\n },\n }\n}\n","/**\n * Revisionable behavior\n * Adds _version field + auto-increment on update\n */\n\nimport { field } from '../fields/builders.js'\nimport type { RevisionableFields } from '../types/infer.js'\nimport type { Behavior } from './types.js'\n\nexport function revisionable(): Behavior<RevisionableFields> {\n return {\n name: 'revisionable',\n fields: {\n // System-managed counter — incremented by `beforeUpdate`. Marked\n // `internal: true` so the HTTP PATCH surface cannot rewrite it.\n // Also redundantly listed in `validation.ts`'s legacy omit set.\n _version: field.number({ required: true, default: 1, integer: true, internal: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n data._version = 1\n return data\n },\n beforeUpdate: async (_id, data) => {\n // Increment version on every update\n data._version = (Number(data._version) || 0) + 1\n return data\n },\n },\n }\n}\n","/**\n * Sluggable behavior\n * Adds slug field + auto-generation from source field\n */\n\nimport { field } from '../fields/builders.js'\nimport type { SluggableFields } from '../types/infer.js'\nimport type { Behavior } from './types.js'\n\nexport interface SluggableOptions {\n /** Make the slug translatable — each locale gets its own slug. Default: false */\n translatable?: boolean\n}\n\n/**\n * Convert text to URL-safe slug\n */\nexport function slugify(text: string): string {\n return text\n .toLowerCase()\n .trim()\n .replace(/[^\\w\\s-]/g, '') // Remove non-word chars except spaces and hyphens\n .replace(/[\\s_-]+/g, '-') // Replace spaces, underscores with single hyphen\n .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens\n}\n\nexport function sluggable(\n sourceField: string,\n options?: SluggableOptions,\n): Behavior<SluggableFields> {\n return {\n name: 'sluggable',\n fields: {\n slug: field.slug({\n from: sourceField,\n ...(options?.translatable ? { translatable: true } : {}),\n }),\n },\n hooks: {\n beforeCreate: async (data) => {\n // Auto-generate slug from source field if not provided\n if (!data.slug && data[sourceField]) {\n data.slug = slugify(String(data[sourceField]))\n }\n return data\n },\n beforeUpdate: async (_id, data) => {\n // Re-generate slug if source field changed and slug not manually set\n if (data[sourceField] && !data.slug) {\n data.slug = slugify(String(data[sourceField]))\n }\n return data\n },\n },\n }\n}\n","/**\n * Timestamped behavior\n * Adds createdAt and updatedAt fields with auto-set logic.\n * Lighter alternative to auditable() when you don't need createdBy/updatedBy tracking.\n */\n\nimport { field } from '../fields/builders.js'\nimport type { Behavior } from './types.js'\n\nexport type TimestampedFields = {\n createdAt: ReturnType<typeof field.date>\n updatedAt: ReturnType<typeof field.date>\n}\n\nexport function timestamped(): Behavior<TimestampedFields> {\n return {\n name: 'timestamped',\n fields: {\n createdAt: field.date({ indexed: true }),\n updatedAt: field.date({ indexed: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n data.createdAt = new Date()\n data.updatedAt = new Date()\n return data\n },\n beforeUpdate: async (_id, data) => {\n data.updatedAt = new Date()\n return data\n },\n },\n }\n}\n","/**\n * Behavior exports\n * All behaviors are exported under the `behavior` namespace\n */\n\nimport { auditable } from './auditable.js'\nimport { hierarchical } from './hierarchical.js'\nimport { publishable } from './publishable.js'\nimport { revisionable } from './revisionable.js'\nimport { sluggable } from './sluggable.js'\nimport { timestamped } from './timestamped.js'\n\nexport { publishable, auditable, sluggable, revisionable, hierarchical, timestamped }\nexport type { SluggableOptions } from './sluggable.js'\nexport { slugify } from './sluggable.js'\nexport type { Behavior, BehaviorContext, BehaviorFactory } from './types.js'\n\n/**\n * Behavior namespace for fluent API\n */\nexport const behavior = {\n publishable,\n auditable,\n sluggable,\n revisionable,\n hierarchical,\n timestamped,\n}\n","/**\n * In-memory TTL cache for COUNT(*) query results.\n *\n * Reduces database load on paginated list pages where the total count\n * is recalculated on every pagination/sort/search interaction. The cache\n * is per-process (not shared across workers) with a short TTL (default 5s)\n * so counts are at most a few seconds stale.\n *\n * Cache keys include the entity name + serialized WHERE clause, so filtered\n * and unfiltered counts are cached independently.\n */\n\n/**\n * Interface for count cache implementations.\n * Used in client configs to avoid TypeScript private-field structural incompatibility\n * across separate .d.ts files.\n */\nexport interface CountCacheLike {\n get(key: string): number | undefined\n set(key: string, count: number): void\n /**\n * Cache-or-compute with single-flight semantics.\n *\n * If the entry is cached and unexpired, returns it immediately (no promise\n * allocation in the hot path). Otherwise, if a refresh is already in flight\n * for the same key, returns that in-flight promise instead of starting a\n * second one — preventing the thundering-herd burst of identical\n * `count(*)` queries when N concurrent requests cross the TTL boundary\n * together.\n */\n getOrCompute(key: string, compute: () => Promise<number>): Promise<number> | number\n invalidate(prefix: string): void\n}\n\ninterface CountCacheEntry {\n count: number\n expiresAt: number\n}\n\nexport class CountCache implements CountCacheLike {\n private cache = new Map<string, CountCacheEntry>()\n private inflight = new Map<string, Promise<number>>()\n private ttlMs: number\n\n constructor(ttlMs = 5000) {\n this.ttlMs = ttlMs\n }\n\n /**\n * Get a cached count. Returns undefined on miss or expired entry.\n */\n get(key: string): number | undefined {\n const entry = this.cache.get(key)\n if (!entry || Date.now() > entry.expiresAt) {\n if (entry) this.cache.delete(key)\n return undefined\n }\n return entry.count\n }\n\n /**\n * Cache a count result with the configured TTL.\n */\n set(key: string, count: number): void {\n this.cache.set(key, { count, expiresAt: Date.now() + this.ttlMs })\n }\n\n /**\n * Cache-or-compute with single-flight semantics. See `CountCacheLike` doc.\n *\n * Cache hits return synchronously (the call site keeps the await but no\n * actual microtask is scheduled). Cache misses register an in-flight\n * promise so concurrent callers share one DB round-trip; the promise is\n * removed once it resolves (or rejects) so the next miss can refresh.\n * On rejection, nothing is cached — the next caller retries.\n */\n getOrCompute(key: string, compute: () => Promise<number>): Promise<number> | number {\n const cached = this.get(key)\n if (cached !== undefined) return cached\n\n const existing = this.inflight.get(key)\n if (existing) return existing\n\n const promise = compute()\n .then((count) => {\n this.set(key, count)\n return count\n })\n .finally(() => {\n // Always clear inflight, even on error, so the next miss can retry.\n this.inflight.delete(key)\n })\n this.inflight.set(key, promise)\n return promise\n }\n\n /**\n * Invalidate all cache entries for a given entity name.\n * Matches the exact entity name OR any key where the entity is followed\n * by one of the cache-key separators (`:` for WHERE, `@` for scope).\n * Avoids accidental cross-entity invalidation (e.g. invalidating 'user'\n * must not clear 'users:where=...' entries).\n */\n invalidate(entityName: string): void {\n for (const key of this.cache.keys()) {\n // Strip optional 'query:' prefix used by QueryClient cache keys\n const stripped = key.startsWith('query:') ? key.slice('query:'.length) : key\n if (\n stripped === entityName ||\n stripped.startsWith(`${entityName}:`) ||\n stripped.startsWith(`${entityName}@`)\n ) {\n this.cache.delete(key)\n }\n }\n }\n\n /**\n * Remove all expired entries. Useful for periodic cleanup in long-running processes.\n */\n prune(): void {\n const now = Date.now()\n for (const [key, entry] of this.cache.entries()) {\n if (now > entry.expiresAt) {\n this.cache.delete(key)\n }\n }\n }\n\n /**\n * Clear the entire cache.\n */\n clear(): void {\n this.cache.clear()\n }\n\n /**\n * Number of entries currently in the cache (including expired ones not yet pruned).\n */\n get size(): number {\n return this.cache.size\n }\n}\n","/**\n * Postgres row count estimation using pg_class statistics.\n *\n * For unfiltered counts on large tables, querying `pg_class.reltuples`\n * is effectively instant (no table scan) and returns a good approximation\n * that is updated by VACUUM and ANALYZE. This is suitable for pagination\n * totals where exact precision is not critical.\n *\n * SECURITY: The table name is NOT interpolated into SQL — it is passed as a\n * parameterized value to the `relname = $1` comparison. This prevents SQL injection.\n */\n\nimport { sql } from 'drizzle-orm'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\n\n/**\n * Estimate the total row count for a table using Postgres statistics.\n *\n * Returns the approximate row count from `pg_class.reltuples`, which is\n * updated by VACUUM/ANALYZE. Returns 0 if the table is not found or\n * statistics are not yet available (e.g., freshly created table).\n *\n * @param db - Drizzle Postgres database instance\n * @param tableName - The Postgres table name (without schema prefix)\n * @returns Estimated row count (non-negative integer)\n */\nexport async function estimateRowCount(db: PostgresJsDatabase, tableName: string): Promise<number> {\n const result = await db.execute(\n sql`SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = ${tableName}`,\n )\n\n const rows = Array.isArray(result) ? result : ((result as { rows?: unknown[] }).rows ?? [])\n const row = rows[0] as { estimate?: string | number } | undefined\n const estimate = Number(row?.estimate ?? 0)\n\n // reltuples can be -1 for tables that have never been analyzed\n return estimate > 0 ? estimate : 0\n}\n","/**\n * Cursor-based (keyset) pagination utilities.\n *\n * Cursor pagination avoids the performance cliff of OFFSET at scale (1M+ rows).\n * Instead of `OFFSET N`, it uses a WHERE condition:\n * `WHERE (sortField < lastValue) OR (sortField = lastValue AND id < lastId)`\n * which Postgres can serve from an index in constant time.\n *\n * The cursor is opaque to the client — base64-encoded JSON.\n *\n * Security:\n * - `field` must be whitelisted against the entity's actual fields\n * - `id` must be a valid UUID\n * - `value` is parameterized (never interpolated into SQL)\n * - Malformed cursors return null (caller returns 400)\n */\n\nimport { and, eq, getTableColumns, gt, lt, or, type SQL } from 'drizzle-orm'\nimport type { PgTable } from 'drizzle-orm/pg-core'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Cursor input for keyset pagination. */\nexport interface CursorInput {\n /** Sort field name (e.g. 'createdAt'). Must be a real column on the entity. */\n field: string\n /** Last seen value of the sort field. */\n value: string | number\n /** Sort direction — must match the ORDER BY direction. */\n direction: 'asc' | 'desc'\n /** Tie-breaker: last seen entity ID. Required for non-unique sort fields. */\n id?: string | undefined\n}\n\n/** Decoded cursor (internal, after validation). */\ninterface DecodedCursor {\n field: string\n value: string | number\n direction: 'asc' | 'desc'\n id?: string | undefined\n}\n\n// ---------------------------------------------------------------------------\n// UUID validation (same format used throughout the toolkit)\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n// ---------------------------------------------------------------------------\n// Encode / decode\n// ---------------------------------------------------------------------------\n\n/**\n * Encode a cursor for API transport (base64url).\n * Built from the last item in a result set.\n */\nexport function encodeCursor(\n item: Record<string, unknown>,\n sortField: string,\n direction: 'asc' | 'desc',\n): string {\n const payload: CursorInput = {\n field: sortField,\n value: item[sortField] as string | number,\n direction,\n id: item.id as string | undefined,\n }\n return btoa(JSON.stringify(payload))\n}\n\n/**\n * Decode and validate a cursor string from query params.\n * Returns null if the cursor is malformed, tampered, or invalid.\n *\n * Security: the `field` value is NOT validated here — the caller must\n * whitelist it against the entity's actual columns.\n */\nexport function decodeCursor(encoded: string): DecodedCursor | null {\n try {\n const json = atob(encoded)\n const parsed: unknown = JSON.parse(json)\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n\n // Validate field\n if (typeof obj.field !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(obj.field)) {\n return null\n }\n\n // Validate value (string or number)\n if (typeof obj.value !== 'string' && typeof obj.value !== 'number') {\n return null\n }\n\n // When the cursor sorts by `id`, the value lands in a UUID-typed\n // column. Reject non-UUID strings here so the caller doesn't surface\n // a Postgres cast error (and side-channel \"valid format vs invalid\n // format\") on the next request.\n if (obj.field === 'id' && (typeof obj.value !== 'string' || !UUID_RE.test(obj.value))) {\n return null\n }\n\n // Validate direction\n if (obj.direction !== 'asc' && obj.direction !== 'desc') {\n return null\n }\n\n // Validate id (optional, must be UUID if present)\n if (obj.id !== undefined) {\n if (typeof obj.id !== 'string' || !UUID_RE.test(obj.id)) {\n return null\n }\n }\n\n return {\n field: obj.field,\n value: obj.value,\n direction: obj.direction,\n id: obj.id as string | undefined,\n }\n } catch {\n return null\n }\n}\n\n// ---------------------------------------------------------------------------\n// SQL condition builder\n// ---------------------------------------------------------------------------\n\n/**\n * Build the keyset WHERE condition from a decoded cursor.\n *\n * For DESC: `WHERE (field < value) OR (field = value AND id < cursorId)`\n * For ASC: `WHERE (field > value) OR (field = value AND id > cursorId)`\n *\n * The caller must verify that `cursor.field` exists on the table before calling.\n *\n * @param table - Drizzle table with columns\n * @param cursor - Decoded and validated cursor\n * @returns SQL condition, or null if the field doesn't exist on the table\n */\nexport function buildCursorCondition(table: PgTable, cursor: DecodedCursor): SQL | null {\n const cols = getTableColumns(table)\n const column = cols[cursor.field]\n if (!column) return null\n\n const isDesc = cursor.direction === 'desc'\n const compare = isDesc ? lt : gt\n\n // Primary condition: sort field passes the cursor value\n const fieldCondition = compare(column, cursor.value)\n\n // Without tie-breaker ID, use simple comparison\n if (!cursor.id) {\n return fieldCondition\n }\n\n // With tie-breaker: (field < value) OR (field = value AND id < cursorId)\n const idColumn = cols.id\n if (!idColumn) return fieldCondition\n\n const idCondition = compare(idColumn, cursor.id)\n // Drizzle's `or`/`and` return SQL when called with at least one defined arg.\n // Fall back to fieldCondition rather than asserting non-null.\n return or(fieldCondition, and(eq(column, cursor.value), idCondition)) ?? fieldCondition\n}\n","/**\n * Entity definition API\n * The core function for defining entities with full type inference.\n *\n * The generic parameters are inferred from the call site:\n * - F is inferred from `definition.fields`\n * - B is inferred from `definition.behaviors` (as a tuple)\n *\n * The returned Entity carries the complete field map:\n * { id: IdField } & BehaviorFields & UserFields\n */\n\nimport type { EntityAdminConfig } from './admin-config.js'\nimport type { Behavior } from './behaviors/types.js'\nimport type { FieldConfig, IdField } from './fields/base.js'\nimport { field } from './fields/builders.js'\n\n/**\n * Entity definition input (without behaviors — behaviors are typed separately\n * on the `defineEntity` function to enable tuple inference).\n *\n * @typeParam F - The literal field map. Inferred automatically from the call site.\n */\nexport interface EntityDefinition<\n F extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n name: string\n kind?: 'collection' | 'singleton'\n fields: F\n scope?: 'global' | 'team' | 'user'\n access?: {\n view?: string\n create?: string\n update?: string\n delete?: string\n }\n /** Admin UI configuration — controls sidebar, list, and form display */\n admin?: EntityAdminConfig\n}\n\n/**\n * Full input for defineEntity (fields + behaviors + other config).\n * Behaviors are typed as a tuple `B` for type extraction.\n */\nexport type EntityInput<\n F extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n B extends Behavior[] = Behavior[],\n> = EntityDefinition<F> & { behaviors?: B }\n\n/**\n * A fully resolved entity with merged behavior fields.\n * @typeParam AllFields - The complete field map (id + behaviors + user fields).\n */\nexport interface Entity<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n name: string\n kind?: 'collection' | 'singleton'\n fields: Record<string, FieldConfig>\n behaviors?: Behavior[]\n scope?: 'global' | 'team' | 'user'\n access?: {\n view?: string\n create?: string\n update?: string\n delete?: string\n }\n /** Admin UI configuration — controls sidebar, list, and form display */\n admin?: EntityAdminConfig\n allFields: AllFields\n}\n\n/**\n * Extract and intersect behavior field types from a behaviors tuple.\n * Walks the tuple recursively (depth bounded by number of behaviors, max ~5).\n *\n * Inferring `F` via `Behavior<infer F1>` is unreliable for inline hook-only\n * behaviors: when the literal has no `fields` property, TS falls back to the\n * `Behavior` constraint bound (`Record<string, FieldConfig>`) rather than the\n * declared default. That bound would widen every `AllFields[K]` access to\n * `FieldConfig` and break per-field type inference downstream (e.g. reference\n * cardinality, select option literals). Instead, derive the field map from\n * the behavior's `fields` property directly — if absent/undefined, contribute\n * nothing (empty object) to the intersection.\n */\ntype BehaviorFieldsOf<B> = B extends { fields?: infer F }\n ? F extends Record<string, FieldConfig>\n ? F\n : // biome-ignore lint/complexity/noBannedTypes: empty object for hook-only behaviors (fields is undefined)\n {}\n : // biome-ignore lint/complexity/noBannedTypes: empty object for behaviors without `fields`\n {}\n\ntype ExtractBehaviorFields<B extends Behavior[]> = B extends [\n infer B1,\n ...infer Rest extends Behavior[],\n]\n ? BehaviorFieldsOf<B1> & ExtractBehaviorFields<Rest>\n : // biome-ignore lint/complexity/noBannedTypes: empty object is correct for base case of intersection\n {}\n\n/**\n * Define an entity with full type inference.\n *\n * @example\n * const Article = defineEntity({\n * name: 'article',\n * fields: {\n * title: field.text({ required: true }),\n * featured: field.boolean(),\n * },\n * behaviors: [publishable(), auditable()],\n * })\n *\n * type ArticleDTO = InferEntity<typeof Article>\n * // { id: string; title: string; featured?: boolean | null; status?: ...; createdAt?: ...; }\n */\nexport function defineEntity<\n F extends Record<string, FieldConfig>,\n const B extends Behavior[] = [],\n>(\n definition: EntityDefinition<F> & { behaviors?: B },\n): Entity<{ id: IdField } & ExtractBehaviorFields<B> & F> {\n // Merge behavior fields into main fields\n const behaviorFields: Record<string, FieldConfig> = {}\n\n for (const behavior of definition.behaviors || []) {\n if (behavior.fields) {\n for (const [fieldName, fieldConfig] of Object.entries(\n behavior.fields as Record<string, FieldConfig>,\n )) {\n if (behaviorFields[fieldName]) {\n console.warn(\n `Field '${fieldName}' from behavior '${behavior.name}' conflicts with existing field. Skipping.`,\n )\n continue\n }\n behaviorFields[fieldName] = fieldConfig\n }\n }\n }\n\n // Build complete field set\n // Order: id (always first) → behavior fields → user-defined fields\n const allFields: Record<string, FieldConfig> = {\n id: field.id(), // Every entity gets an ID\n ...behaviorFields,\n ...definition.fields,\n }\n\n // Auto-infer translatable slugs: if a slug field's source field is translatable,\n // the slug should be translatable too (each locale gets its own URL-safe slug).\n for (const [, fieldConfig] of Object.entries(allFields)) {\n if (\n fieldConfig.type === 'slug' &&\n !fieldConfig.translatable &&\n allFields[fieldConfig.from]?.translatable\n ) {\n fieldConfig.translatable = true\n }\n }\n\n // The cast through `unknown` is safe: runtime merge order (id + behaviors + user fields)\n // exactly mirrors the type-level intersection. TypeScript cannot verify imperative\n // Object.entries() loops produce the same result as a type-level intersection,\n // so we cast at this single auditable boundary.\n return {\n ...definition,\n allFields,\n } as unknown as Entity<{ id: IdField } & ExtractBehaviorFields<B> & F>\n}\n","/**\n * Error thrown when attempting to delete an entity that is still referenced.\n */\n\nimport type { EntityUsage } from './find-usages.js'\n\nexport class ReferencedEntityError extends Error {\n public readonly entityName: string\n public readonly entityId: string\n public readonly usages: EntityUsage[]\n\n constructor(entityName: string, entityId: string, usages: EntityUsage[]) {\n const count = usages.length\n super(\n `Cannot delete ${entityName} '${entityId}': referenced by ${count} other entit${count === 1 ? 'y' : 'ies'}`,\n )\n this.name = 'ReferencedEntityError'\n this.entityName = entityName\n this.entityId = entityId\n this.usages = usages\n }\n}\n","/**\n * entity_refs — Universal reference tracking table.\n *\n * Every reference between entities (field.media(), field.reference(), block media fields)\n * gets a row here at write time. This enables:\n * - Instant \"where is this used?\" queries (one indexed lookup)\n * - Universal delete protection (can't delete referenced entities)\n * - Correct results (schema-aware, no LIKE text search)\n */\n\nimport { index, pgTable, unique, uuid, varchar } from 'drizzle-orm/pg-core'\n\nexport const entityRefs = pgTable(\n 'entity_refs',\n {\n sourceEntity: varchar('source_entity', { length: 100 }).notNull(),\n sourceId: uuid('source_id').notNull(),\n sourceField: varchar('source_field', { length: 100 }).notNull(),\n targetEntity: varchar('target_entity', { length: 100 }).notNull(),\n targetId: uuid('target_id').notNull(),\n },\n (t) => [\n unique('uq_entity_refs').on(\n t.sourceEntity,\n t.sourceId,\n t.sourceField,\n t.targetEntity,\n t.targetId,\n ),\n index('idx_entity_refs_target').on(t.targetEntity, t.targetId),\n index('idx_entity_refs_source').on(t.sourceEntity, t.sourceId),\n ],\n)\n","/**\n * Schema generator\n * Converts entity definitions to Drizzle schemas.\n * Every field gets its own Postgres column — no JSONB blobs.\n */\n\nimport { getTableColumns } from 'drizzle-orm'\nimport type { PgColumnBuilderBase, PgTable } from 'drizzle-orm/pg-core'\nimport {\n boolean,\n doublePrecision,\n index,\n integer,\n jsonb,\n pgTable,\n text,\n timestamp,\n unique,\n uuid,\n varchar,\n} from 'drizzle-orm/pg-core'\nimport type { Entity } from './define-entity.js'\nimport type { FieldConfig } from './fields/base.js'\nimport { entityRefs } from './refs/schema.js'\n\n/**\n * Resolve the `id` column of a parent entity table for a `.references()` FK.\n * `getTableColumns(t).id` is typed as `T | undefined` under the strict\n * indexed-access flag — but every entity table has an `id` column by\n * construction (`generateEntitySchema` always adds it), so the lookup is\n * a runtime invariant.\n */\nfunction parentIdCol(parent: PgTable, entityName: string) {\n const idCol = getTableColumns(parent).id\n if (!idCol) throw new Error(`Parent table for \"${entityName}\" has no \"id\" column`)\n return idCol\n}\n\n/**\n * Convert a field config to a Drizzle column.\n *\n * @param nullable - When true, skips notNull and default constraints.\n * Used for translation table columns (translations are partial overrides).\n */\nexport function fieldToColumn(\n fieldName: string,\n fieldConfig: FieldConfig,\n options?: { nullable?: boolean },\n): PgColumnBuilderBase {\n const nullable = options?.nullable ?? false\n\n switch (fieldConfig.type) {\n case 'id':\n return uuid(fieldName).primaryKey().defaultRandom()\n\n case 'text':\n if (fieldConfig.maxLength && fieldConfig.maxLength <= 255) {\n let column = varchar(fieldName, { length: fieldConfig.maxLength })\n if (fieldConfig.unique) column = column.unique()\n if (!nullable && fieldConfig.required) column = column.notNull()\n if (!nullable && fieldConfig.default !== undefined)\n column = column.default(fieldConfig.default as string)\n return column\n } else {\n let column = text(fieldName)\n if (fieldConfig.unique) column = column.unique()\n if (!nullable && fieldConfig.required) column = column.notNull()\n if (!nullable && fieldConfig.default !== undefined)\n column = column.default(fieldConfig.default as string)\n return column\n }\n\n case 'number': {\n let numColumn = fieldConfig.integer ? integer(fieldName) : doublePrecision(fieldName)\n if (!nullable && fieldConfig.required) numColumn = numColumn.notNull()\n if (!nullable && fieldConfig.default !== undefined)\n numColumn = numColumn.default(fieldConfig.default as number)\n return numColumn\n }\n\n case 'boolean': {\n let boolColumn = boolean(fieldName)\n if (!nullable && fieldConfig.required) boolColumn = boolColumn.notNull()\n if (!nullable) {\n const boolDefault =\n fieldConfig.default !== undefined ? (fieldConfig.default as boolean) : false\n boolColumn = boolColumn.default(boolDefault)\n }\n return boolColumn\n }\n\n case 'date': {\n let dateColumn = timestamp(fieldName, { withTimezone: true })\n if (!nullable && fieldConfig.required) dateColumn = dateColumn.notNull()\n if (!nullable && fieldConfig.default !== undefined)\n dateColumn = dateColumn.default(fieldConfig.default as Date)\n return dateColumn\n }\n\n case 'select': {\n let selectColumn = varchar(fieldName, { length: 100 })\n if (!nullable && fieldConfig.required) selectColumn = selectColumn.notNull()\n if (!nullable && fieldConfig.default !== undefined)\n selectColumn = selectColumn.default(fieldConfig.default as string)\n return selectColumn\n }\n\n case 'reference': {\n if (fieldConfig.cardinality === 'many') {\n return uuid(fieldName).array()\n }\n let refColumn = uuid(fieldName)\n if (!nullable && fieldConfig.required) refColumn = refColumn.notNull()\n return refColumn\n }\n\n case 'media': {\n let mediaColumn = uuid(fieldName)\n if (!nullable && fieldConfig.required) mediaColumn = mediaColumn.notNull()\n return mediaColumn\n }\n\n case 'slug': {\n let slugColumn = varchar(fieldName, { length: 255 })\n // For translation tables (nullable=true), skip per-column unique —\n // translatable slugs use composite UNIQUE(slug, locale) at table level instead.\n if (fieldConfig.unique && !nullable) slugColumn = slugColumn.unique()\n if (!nullable && fieldConfig.required) slugColumn = slugColumn.notNull()\n return slugColumn\n }\n\n case 'richtext': {\n // TipTap HTML strings stored as text\n let rtColumn = text(fieldName)\n if (!nullable && fieldConfig.required) rtColumn = rtColumn.notNull()\n return rtColumn\n }\n\n case 'json':\n return jsonb(fieldName)\n\n default:\n return text(fieldName)\n }\n}\n\n/**\n * Generate a runtime Drizzle schema from an entity definition.\n * Every field becomes its own column — no JSONB.\n */\nexport function generateSchema(entity: Entity) {\n const tableName = entity.name\n const columns: Record<string, PgColumnBuilderBase> = {}\n\n for (const [fieldName, fieldConfig] of Object.entries(entity.allFields)) {\n if (fieldConfig.type === 'blocks') continue // stored in layout table\n columns[fieldName] = fieldToColumn(fieldName, fieldConfig)\n }\n\n if (entity.scope === 'team' || entity.scope === 'user') {\n columns._scopeId = uuid('_scope_id')\n }\n\n // Collect fields that need indexes (skip id/unique — they already have btree indexes)\n const indexedFields = Object.entries(entity.allFields).filter(\n ([_, config]) =>\n config.type !== 'blocks' && config.type !== 'id' && config.indexed && !config.unique,\n )\n\n const isPublishableEntity = entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n\n if (indexedFields.length === 0 && !isPublishableEntity) {\n return pgTable(tableName, columns)\n }\n\n return pgTable(tableName, columns, (table) => {\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns from pgTable callback\n const constraints: Record<string, any> = {}\n\n for (const [fieldName] of indexedFields) {\n const col = table[fieldName]\n if (!col) {\n throw new Error(\n `generateSchema: indexed field \"${fieldName}\" missing from \"${tableName}\" pgTable`,\n )\n }\n constraints[`idx_${tableName}_${fieldName}`] = index(`idx_${tableName}_${fieldName}`).on(col)\n }\n\n // Composite index for publishable entities: status + createdAt (most common list query)\n const statusCol = table.status\n const createdAtCol = table.createdAt\n if (isPublishableEntity && statusCol && createdAtCol) {\n constraints[`idx_${tableName}_status_created`] = index(`idx_${tableName}_status_created`).on(\n statusCol,\n createdAtCol,\n )\n }\n\n return constraints\n })\n}\n\n/**\n * Generate runtime translation table schema.\n * Each translatable field gets its own nullable column.\n *\n * When `parent` is provided, `entityId` gets a `.references()` FK — used\n * by `lumi migrate` so generated SQL includes `REFERENCES parent(id) ON\n * DELETE CASCADE`. Callers that only need the table shape for query\n * building (core/app.ts, content/plugin.ts) can omit `parent`.\n */\nexport function generateTranslationSchema(entity: Entity, parent?: PgTable) {\n const translatableFields = Object.entries(entity.allFields).filter(\n ([_, config]) => config.translatable,\n )\n\n if (translatableFields.length === 0) return null\n\n const tableName = `${entity.name}_translations`\n const entityIdCol = parent\n ? uuid('entity_id')\n .notNull()\n .references(() => parentIdCol(parent, entity.name), { onDelete: 'cascade' })\n : uuid('entity_id').notNull()\n const columns: Record<string, PgColumnBuilderBase> = {\n id: uuid('id').primaryKey().defaultRandom(),\n entityId: entityIdCol,\n locale: varchar('locale', { length: 10 }).notNull(),\n }\n\n const hasTranslatableSlug = translatableFields.some(([_, config]) => config.type === 'slug')\n\n for (const [fieldName, fieldConfig] of translatableFields) {\n columns[fieldName] = fieldToColumn(fieldName, fieldConfig, { nullable: true })\n }\n\n return pgTable(tableName, columns, (table) => {\n const entityIdT = table.entityId\n const localeT = table.locale\n if (!entityIdT || !localeT) {\n throw new Error(`Translation table for \"${entity.name}\" missing entityId/locale columns`)\n }\n const slugT = table.slug\n return {\n uniqueEntityLocale: unique().on(entityIdT, localeT),\n // Per-locale slug uniqueness: no two entities can share the same slug in the same locale\n ...(hasTranslatableSlug && slugT\n ? { uniqueSlugLocale: unique().on(slugT, localeT) }\n : {}),\n }\n })\n}\n\n/**\n * Check if an entity has any blocks fields\n */\nexport function hasBlocksFields(entity: Entity): boolean {\n return Object.values(entity.allFields).some((f) => f.type === 'blocks')\n}\n\n/**\n * Generate layout table schema for entities with blocks fields.\n * Stores block instances in a separate table with ordering.\n *\n * When blocks field has localized: false (default), locale is NULL (shared layout).\n * When blocks field has localized: true, locale is set per-locale.\n *\n * `parent` is optional — when provided, emits a `.references()` FK (for\n * migration generation); query-runtime callers can omit it.\n */\nexport function generateLayoutSchema(entity: Entity, parent?: PgTable) {\n if (!hasBlocksFields(entity)) return null\n\n const tableName = `${entity.name}_layout`\n const entityIdCol = parent\n ? uuid('entity_id')\n .notNull()\n .references(() => parentIdCol(parent, entity.name), { onDelete: 'cascade' })\n : uuid('entity_id').notNull()\n\n return pgTable(\n tableName,\n {\n id: uuid('id').primaryKey().defaultRandom(),\n entityId: entityIdCol,\n fieldName: varchar('field_name', { length: 100 }).notNull(),\n blockType: varchar('block_type', { length: 100 }).notNull(),\n sortOrder: integer('sort_order').notNull().default(0),\n data: jsonb('data'),\n locale: varchar('locale', { length: 10 }),\n },\n (table) => ({\n // Covers: WHERE entity_id IN (...) AND locale IS NULL ORDER BY sort_order\n idx_entity_locale_sort: index(`idx_${tableName}_entity_locale_sort`).on(\n table.entityId,\n table.locale,\n table.sortOrder,\n ),\n }),\n )\n}\n\n/**\n * Check whether an entity has a behavior with the given name.\n *\n * Single source of truth replacing per-behavior predicates that used to\n * inline the `entity.behaviors?.some(b => b.name === 'X') ?? false`\n * pattern across the codebase. Use this whenever you need to branch on a\n * behavior's presence — admin UI, schema generation, route dispatch, etc.\n */\nexport function hasBehavior(entity: Entity, name: string): boolean {\n return entity.behaviors?.some((b) => b.name === name) ?? false\n}\n\n/**\n * Check if an entity has the versionable behavior\n */\nexport function isVersionable(entity: Entity): boolean {\n return hasBehavior(entity, 'versionable')\n}\n\n/**\n * Check if an entity needs a per-locale publish status table.\n * Requires both publishable() behavior AND at least one translatable field.\n */\nexport function needsLocaleStatus(entity: Entity): boolean {\n if (!hasBehavior(entity, 'publishable')) return false\n const allFields = entity.allFields as Record<string, { translatable?: boolean }>\n return Object.values(allFields).some((f) => f.translatable)\n}\n\n/**\n * Check if an entity is publishable (has publishable() behavior).\n */\nexport function isPublishable(entity: Entity): boolean {\n return hasBehavior(entity, 'publishable')\n}\n\n// ---------------------------------------------------------------------------\n// Runtime generators for infrastructure tables attached to an entity.\n//\n// Each one takes the parent entity's runtime `pgTable` so the\n// `.references()` FK is wired. These replace the old `generate*Code()`\n// string helpers that produced source lines to be concatenated into\n// `generated/schema.ts`.\n// ---------------------------------------------------------------------------\n\n/** Per-locale publish status table for publishable + translatable entities. */\nexport function generateLocaleStatusSchema(entity: Entity, parent: PgTable) {\n const tableName = `${entity.name}_locale_status`\n return pgTable(\n tableName,\n {\n id: uuid('id').primaryKey().defaultRandom(),\n entityId: uuid('entity_id')\n .notNull()\n .references(() => parentIdCol(parent, entity.name), { onDelete: 'cascade' }),\n locale: varchar('locale', { length: 10 }).notNull(),\n status: varchar('status', { length: 20 }).notNull().default('draft'),\n publishedAt: timestamp('published_at', { withTimezone: true }),\n },\n (table) => ({\n uniqueEntityLocale: unique().on(table.entityId, table.locale),\n }),\n )\n}\n\n/** Drafts overlay table for publishable entities. */\nexport function generateDraftsSchema(entity: Entity, parent: PgTable) {\n const tableName = `${entity.name}_drafts`\n return pgTable(\n tableName,\n {\n id: uuid('id').primaryKey().defaultRandom(),\n entityId: uuid('entity_id')\n .notNull()\n .references(() => parentIdCol(parent, entity.name), { onDelete: 'cascade' }),\n locale: varchar('locale', { length: 10 }).notNull().default('_'),\n data: jsonb('data').notNull(),\n createdBy: varchar('created_by', { length: 255 }).notNull(),\n createdByName: varchar('created_by_name', { length: 255 }),\n createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),\n },\n (table) => ({\n uniqueEntityLocale: unique().on(table.entityId, table.locale),\n }),\n )\n}\n\n/** Versions history table for versionable entities. */\nexport function generateVersionsSchema(entity: Entity, parent: PgTable) {\n const tableName = `${entity.name}_versions`\n return pgTable(\n tableName,\n {\n id: uuid('id').primaryKey().defaultRandom(),\n entityId: uuid('entity_id')\n .notNull()\n .references(() => parentIdCol(parent, entity.name), { onDelete: 'cascade' }),\n version: integer('version').notNull(),\n locale: varchar('locale', { length: 10 }).notNull().default('_'),\n data: jsonb('data').notNull(),\n delta: jsonb('delta'),\n status: varchar('status', { length: 20 }),\n createdBy: varchar('created_by', { length: 255 }),\n createdByName: varchar('created_by_name', { length: 255 }),\n createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n isAutosave: boolean('is_autosave').notNull().default(false),\n },\n (table) => ({\n uniqueEntityVersionLocale: unique().on(table.entityId, table.version, table.locale),\n }),\n )\n}\n\n/**\n * Shared content locks table — one per project, independent of any entity.\n * Included exactly once in `buildRuntimeSchema()` when any entity is publishable.\n */\nexport function generateContentLocksSchema() {\n return pgTable(\n 'toolkit_content_locks',\n {\n id: uuid('id').primaryKey().defaultRandom(),\n entityType: varchar('entity_type', { length: 100 }).notNull(),\n entityId: varchar('entity_id', { length: 255 }).notNull(),\n locale: varchar('locale', { length: 10 }).notNull().default('_'),\n lockedBy: varchar('locked_by', { length: 255 }).notNull(),\n lockedByName: varchar('locked_by_name', { length: 255 }),\n lockedAt: timestamp('locked_at', { withTimezone: true }).notNull().defaultNow(),\n expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),\n },\n (table) => ({\n uniqueEntityLock: unique().on(table.entityType, table.entityId, table.locale),\n }),\n )\n}\n\n/**\n * Check if an entity has blocks fields with translatable block content (non-localized mode).\n */\nexport function hasTranslatableBlocks(entity: Entity): boolean {\n const allFields = entity.allFields as Record<\n string,\n {\n type: string\n localized?: boolean\n blocks?: Array<{ fields: Record<string, { translatable?: boolean }> }>\n }\n >\n return Object.values(allFields).some((f) => {\n if (f.type !== 'blocks') return false\n if (f.localized) return false\n return f.blocks?.some((block) => Object.values(block.fields).some((bf) => bf.translatable))\n })\n}\n\n/**\n * Generate layout translation table for non-localized blocks with translatable fields.\n *\n * `layoutParent` is optional — when provided, emits a `.references()` FK\n * pointing at the corresponding `{entity}_layout` table (for migration\n * generation).\n */\nexport function generateLayoutTranslationSchema(entity: Entity, layoutParent?: PgTable) {\n const blocksFields = Object.values(entity.allFields).filter(\n (f) => f.type === 'blocks' && !('localized' in f && f.localized),\n )\n\n if (blocksFields.length === 0) return null\n\n const hasTranslatableBlockFields = blocksFields.some((bf) => {\n if (bf.type !== 'blocks') return false\n return bf.blocks.some((block) => Object.values(block.fields).some((f) => f.translatable))\n })\n\n if (!hasTranslatableBlockFields) return null\n\n const tableName = `${entity.name}_layout_translations`\n const layoutIdCol = layoutParent\n ? uuid('layout_id')\n .notNull()\n .references(() => parentIdCol(layoutParent, `${entity.name} layout`), {\n onDelete: 'cascade',\n })\n : uuid('layout_id').notNull()\n\n return pgTable(\n tableName,\n {\n id: uuid('id').primaryKey().defaultRandom(),\n layoutId: layoutIdCol,\n locale: varchar('locale', { length: 10 }).notNull(),\n fields: jsonb('fields').notNull(),\n },\n (table) => ({\n uniqueLayoutLocale: unique().on(table.layoutId, table.locale),\n }),\n )\n}\n\n/**\n * Build the full runtime schema map for a list of entities.\n *\n * Returns a `Record<string, PgTable>` suitable for feeding into\n * `drizzle-kit/api`'s `generateDrizzleJson()`. Includes every table\n * that would have been emitted into `generated/schema.ts` by the old\n * codegen pipeline: main entity, translations, layout, layout\n * translations, versions, drafts, locale status, and the shared\n * content locks table.\n *\n * This is the single entry point used by `lumi migrate` — plugin\n * internal tables are added separately by reading each plugin's\n * `tables` field.\n */\nexport function buildEntitySchemaMap(entities: Entity[]): Record<string, PgTable> {\n const schema: Record<string, PgTable> = {}\n let hasAnyPublishable = false\n\n for (const entity of entities) {\n const main = generateSchema(entity)\n schema[entity.name] = main\n\n const translations = generateTranslationSchema(entity, main)\n if (translations) schema[`${entity.name}_translations`] = translations\n\n if (hasBlocksFields(entity)) {\n const layout = generateLayoutSchema(entity, main)\n if (layout) {\n schema[`${entity.name}_layout`] = layout\n const layoutTrans = generateLayoutTranslationSchema(entity, layout)\n if (layoutTrans) schema[`${entity.name}_layout_translations`] = layoutTrans\n }\n }\n\n if (isVersionable(entity)) {\n schema[`${entity.name}_versions`] = generateVersionsSchema(entity, main)\n }\n if (needsLocaleStatus(entity)) {\n schema[`${entity.name}_locale_status`] = generateLocaleStatusSchema(entity, main)\n }\n if (isPublishable(entity)) {\n schema[`${entity.name}_drafts`] = generateDraftsSchema(entity, main)\n hasAnyPublishable = true\n }\n }\n\n if (hasAnyPublishable) {\n schema.toolkit_content_locks = generateContentLocksSchema()\n }\n\n // Universal reference tracking table — used by AdminClient.syncRefs()\n // for delete protection and cross-entity usage queries.\n schema.entity_refs = entityRefs\n\n return schema\n}\n","/**\n * Shared entity data operations.\n *\n * Extracted from AdminClient and QueryClient to eliminate duplication.\n * These are standalone functions that take an EntityContext — both clients\n * satisfy this interface via `this`.\n */\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { and, eq, inArray, isNull, or, type SQL } from 'drizzle-orm'\nimport type { PgTableWithColumns } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { BehaviorContext } from '../behaviors/types.js'\nimport type { Entity } from '../define-entity.js'\nimport type { FieldConfig } from '../fields/base.js'\n\n// ---------------------------------------------------------------------------\n// Context interface — satisfied by both AdminClient and QueryClient via `this`\n// ---------------------------------------------------------------------------\n\n/**\n * Security context resolved per-request. Provided by the consumer\n * (e.g., via React.cache() in Next.js, or runAsCli for CLI).\n * The entity package has zero knowledge of how this is resolved.\n */\nexport interface SecurityContext {\n user: { id: string; groups: string[]; name?: string; email?: string }\n checker: (role: string, resource: string, action: string) => boolean\n scope?: { type: string; id: string }\n}\n\n/**\n * Function that resolves the current request's security context.\n * Injected at construction time — the entity package never imports\n * @murumets-ee/core or any framework-specific code.\n *\n * Returns undefined only when intentionally skipping enforcement\n * (should not happen in production — throw ForbiddenError instead).\n */\nexport type ContextResolver = () =>\n | SecurityContext\n | undefined\n | Promise<SecurityContext | undefined>\n\nexport interface EntityContext {\n entity: Entity\n db: PostgresJsDatabase\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns require PgTableWithColumns<any>\n table: PgTableWithColumns<any>\n /** Resolves the current request's security context. */\n resolveContext?: ContextResolver | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Field accessors\n// ---------------------------------------------------------------------------\n\n/** Get the entity's full field map with proper typing. Eliminates repeated casts. */\nexport function getAllFields(ctx: EntityContext): Record<string, FieldConfig> {\n return ctx.entity.allFields as Record<string, FieldConfig>\n}\n\n/** Get all blocks field definitions for this entity. */\nexport function getBlocksFields(ctx: EntityContext): Array<{ name: string; config: FieldConfig }> {\n return Object.entries(getAllFields(ctx))\n .filter(([_, config]) => config.type === 'blocks')\n .map(([name, config]) => ({ name, config }))\n}\n\n// ---------------------------------------------------------------------------\n// Translation merging\n// ---------------------------------------------------------------------------\n\n/**\n * Merge translations into entities for the specified locale.\n * Reads translatable field values from real columns on the translation row.\n */\nexport async function mergeTranslations<T extends Record<string, unknown>>(\n ctx: EntityContext,\n entities: T[],\n locale: string,\n): Promise<T[]> {\n if (!entities.length) return entities\n\n const translationTableName = `${ctx.entity.name}_translations`\n const translationTable = schemaRegistry.get(translationTableName)\n\n if (!translationTable) return entities\n\n const entityIds = entities.map((e) => e.id)\n\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(and(inArray(translationTable.entityId, entityIds), eq(translationTable.locale, locale)))\n\n // Get translatable field names\n const translatableFields = Object.entries(getAllFields(ctx))\n .filter(([_, config]) => config.translatable)\n .map(([name]) => name)\n\n const translationMap = new Map<unknown, Record<string, unknown>>()\n for (const translation of translations) {\n const translatedValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n const value = (translation as Record<string, unknown>)[fieldName]\n if (value !== undefined && value !== null) {\n translatedValues[fieldName] = value\n }\n }\n translationMap.set(translation.entityId, translatedValues)\n }\n\n return entities.map((entity) => {\n const translatedFields = translationMap.get(entity.id)\n if (!translatedFields) return entity\n return { ...entity, ...translatedFields }\n })\n}\n\n// ---------------------------------------------------------------------------\n// Block loading\n// ---------------------------------------------------------------------------\n\nexport interface LoadBlocksOptions {\n defaultLocale?: string | undefined\n /**\n * When true (admin editing), clears untranslated translatable block fields\n * to empty string — signals incomplete translation in the editing UI.\n * When false (visitor-facing), preserves base data as fallback.\n */\n strictTranslations?: boolean | undefined\n}\n\n/**\n * Load blocks for one or more entities from the layout table.\n *\n * Handles both block translation modes:\n * - Shared layout (localized: false): loads locale=NULL rows, merges translations\n * - Per-locale layout (localized: true): loads rows matching the provided locale only\n */\nexport async function loadBlocks(\n ctx: EntityContext,\n entityIds: string[],\n locale?: string,\n options?: LoadBlocksOptions,\n): Promise<Map<string, Record<string, unknown[]>>> {\n const blocksFields = getBlocksFields(ctx)\n if (blocksFields.length === 0 || entityIds.length === 0) {\n return new Map()\n }\n\n const layoutTable = schemaRegistry.get(`${ctx.entity.name}_layout`)\n if (!layoutTable) return new Map()\n\n // Determine block field modes\n const sharedFields = blocksFields.filter(\n ({ config }) => !('localized' in config && config.localized),\n )\n const localizedFields = blocksFields.filter(\n ({ config }) => 'localized' in config && config.localized,\n )\n\n const result = new Map<string, Record<string, unknown[]>>()\n\n // ---- Shared blocks: locale IS NULL, translations from layout_translations ----\n if (sharedFields.length > 0) {\n const rows = await ctx.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), isNull(layoutTable.locale)))\n .orderBy(layoutTable.sortOrder)\n\n // Load translations if locale provided\n let blockTransMap: Map<string, Record<string, unknown>> | undefined\n if (locale && rows.length > 0) {\n const layoutTransTable = schemaRegistry.get(`${ctx.entity.name}_layout_translations`)\n if (layoutTransTable) {\n const layoutIds = rows.map((r) => r.id as string)\n const translations = await ctx.db\n .select()\n .from(layoutTransTable)\n .where(\n and(inArray(layoutTransTable.layoutId, layoutIds), eq(layoutTransTable.locale, locale)),\n )\n\n blockTransMap = new Map()\n for (const t of translations) {\n blockTransMap.set(t.layoutId as string, (t.fields as Record<string, unknown>) ?? {})\n }\n }\n }\n\n // Build block definition lookup for translatable field clearing (strict mode only)\n let blockDefMap: Map<string, Record<string, FieldConfig>> | undefined\n if (options?.strictTranslations) {\n blockDefMap = new Map()\n for (const { config } of sharedFields) {\n if (config.type !== 'blocks') continue\n for (const def of config.blocks) {\n blockDefMap.set(def.slug, def.fields)\n }\n }\n }\n\n for (const row of rows) {\n const eid = row.entityId as string\n const fname = row.fieldName as string\n\n let entityBlocks = result.get(eid)\n if (!entityBlocks) {\n entityBlocks = {}\n result.set(eid, entityBlocks)\n }\n if (!entityBlocks[fname]) entityBlocks[fname] = []\n\n const blockData = (row.data as Record<string, unknown>) ?? {}\n const translatedFields = blockTransMap?.get(row.id as string)\n\n // Build the block object\n const block: Record<string, unknown> = {\n _block: row.blockType,\n _id: row.id,\n ...blockData,\n ...(translatedFields ?? {}),\n }\n\n // Strict mode: clear untranslated translatable fields when loading for non-default locale\n if (locale && blockDefMap) {\n const blockType = row.blockType as string\n const fieldDefs = blockDefMap.get(blockType)\n if (fieldDefs) {\n for (const [fieldName, fieldConfig] of Object.entries(fieldDefs)) {\n if (fieldConfig.translatable && !translatedFields?.[fieldName]) {\n block[fieldName] = ''\n }\n }\n }\n }\n\n entityBlocks[fname].push(block)\n }\n }\n\n // ---- Localized blocks: filter by locale column ----\n // NULL rows (from initial create) only fall back for the default locale.\n // Non-default locales without locale-specific rows get an empty array.\n if (localizedFields.length > 0) {\n const isDefault = !locale || locale === options?.defaultLocale\n let localeCondition: SQL\n if (locale) {\n if (isDefault) {\n // Both args defined → drizzle's `or` always returns SQL; fall back defensively\n localeCondition =\n or(eq(layoutTable.locale, locale), isNull(layoutTable.locale)) ??\n eq(layoutTable.locale, locale)\n } else {\n localeCondition = eq(layoutTable.locale, locale)\n }\n } else {\n localeCondition = isNull(layoutTable.locale)\n }\n\n const rows = await ctx.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), localeCondition))\n .orderBy(layoutTable.sortOrder)\n\n // Group by entityId + fieldName, prefer locale-specific rows over NULL\n const grouped = new Map<string, { localeRows: typeof rows; nullRows: typeof rows }>()\n for (const row of rows) {\n const key = `${row.entityId}::${row.fieldName}`\n let group = grouped.get(key)\n if (!group) {\n group = { localeRows: [], nullRows: [] }\n grouped.set(key, group)\n }\n if (row.locale) {\n group.localeRows.push(row)\n } else {\n group.nullRows.push(row)\n }\n }\n\n for (const [, { localeRows, nullRows }] of grouped) {\n // Use locale-specific rows if available, otherwise fall back to NULL\n const effective = localeRows.length > 0 ? localeRows : nullRows\n\n for (const row of effective) {\n const eid = row.entityId as string\n const fname = row.fieldName as string\n\n let entityBlocks = result.get(eid)\n if (!entityBlocks) {\n entityBlocks = {}\n result.set(eid, entityBlocks)\n }\n if (!entityBlocks[fname]) entityBlocks[fname] = []\n\n const blockData = (row.data as Record<string, unknown>) ?? {}\n\n entityBlocks[fname].push({\n _block: row.blockType,\n _id: row.id,\n ...blockData,\n })\n }\n }\n }\n\n return result\n}\n\n/**\n * Attach loaded blocks to shaped DTOs.\n */\nexport function attachBlocks(\n ctx: EntityContext,\n entities: Record<string, unknown>[],\n blocksMap: Map<string, Record<string, unknown[]>>,\n): void {\n const blocksFields = getBlocksFields(ctx)\n if (blocksFields.length === 0) return\n\n for (const entity of entities) {\n const eid = entity.id as string\n const entityBlocks = blocksMap.get(eid) ?? {}\n\n for (const { name } of blocksFields) {\n entity[name] = entityBlocks[name] ?? []\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Count cache key\n// ---------------------------------------------------------------------------\n\n/**\n * Build a cache key for a count query.\n * @param prefix - Optional namespace prefix (e.g. 'query' for QueryClient)\n * @param scopeId - Optional scope ID for multi-tenant isolation\n */\nexport function buildCountCacheKey(\n entityName: string,\n where?: SQL,\n prefix?: string,\n scopeId?: string,\n): string {\n let base = prefix ? `${prefix}:${entityName}` : entityName\n if (scopeId) base = `${base}@${scopeId}`\n if (!where) return base\n // Use SQL's toString() representation as a stable key.\n // This is a display string, not executed — safe for cache keying.\n return `${base}:${String(where)}`\n}\n\n// ---------------------------------------------------------------------------\n// ForbiddenError\n// ---------------------------------------------------------------------------\n\n/**\n * Thrown when a user lacks permission for an entity operation.\n * Consumers (api-handler) catch this and return HTTP 403.\n */\nexport class ForbiddenError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'ForbiddenError'\n }\n}\n\n// ---------------------------------------------------------------------------\n// Context helpers — dynamic imports to avoid entity → core circular dep\n// ---------------------------------------------------------------------------\n\n// ---------------------------------------------------------------------------\n// Context resolution — framework-agnostic\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve the security context from the resolver on EntityContext.\n * Throws ForbiddenError if no resolver is configured or if it returns undefined.\n */\nasync function resolveSecurityContext(\n resolver: ContextResolver | undefined,\n): Promise<SecurityContext> {\n if (!resolver) {\n throw new ForbiddenError(\n 'No context resolver configured on AdminClient/QueryClient. ' +\n 'Use createAdminClient() from @murumets-ee/core/clients, or pass a contextResolver in config.',\n )\n }\n\n const ctx = await resolver()\n\n if (!ctx) {\n throw new ForbiddenError(\n 'Context resolver returned no context. ' +\n 'Ensure auth is configured in your request handler, or use runAsCli() for CLI scripts.',\n )\n }\n\n return ctx\n}\n\n// ---------------------------------------------------------------------------\n// Permission enforcement\n// ---------------------------------------------------------------------------\n\n/**\n * Assert the current user has permission for the given action on the entity.\n * Uses the PermissionChecker from the resolved SecurityContext.\n */\nexport async function assertEntityAccess(\n entity: Entity,\n resolver: ContextResolver | undefined,\n action: 'view' | 'create' | 'update' | 'delete',\n): Promise<void> {\n const ctx = await resolveSecurityContext(resolver)\n\n const role = ctx.user.groups[0]\n if (!role) {\n throw new ForbiddenError(`User '${ctx.user.id}' has no role assigned.`)\n }\n\n if (!ctx.checker(role, entity.name, action)) {\n throw new ForbiddenError(`Forbidden: role '${role}' cannot ${action} '${entity.name}'`)\n }\n}\n\n// ---------------------------------------------------------------------------\n// Scope filtering\n// ---------------------------------------------------------------------------\n\n/**\n * Get the current scope ID for a scoped entity.\n * Returns undefined for global entities.\n */\nexport async function getScopeId(\n entity: Entity,\n resolver: ContextResolver | undefined,\n): Promise<string | undefined> {\n if (!entity.scope || entity.scope === 'global') return undefined\n\n const ctx = await resolveSecurityContext(resolver)\n return ctx.scope?.id\n}\n\n/**\n * Build a WHERE condition for scope filtering.\n */\nexport function buildScopeCondition(ctx: EntityContext, scopeId: string): SQL {\n return eq(ctx.table._scopeId, scopeId)\n}\n\n/**\n * Require scope for a scoped entity. Throws if no scope is in context.\n * Returns undefined for global entities.\n */\nexport async function requireScopeForEntity(\n entity: Entity,\n resolver: ContextResolver | undefined,\n): Promise<string | undefined> {\n if (!entity.scope || entity.scope === 'global') return undefined\n\n const ctx = await resolveSecurityContext(resolver)\n const scopeId = ctx.scope?.id\n\n if (!scopeId) {\n throw new ForbiddenError(\n `Entity '${entity.name}' requires scope '${entity.scope}' but no scope is set in context.`,\n )\n }\n\n return scopeId\n}\n\n// ---------------------------------------------------------------------------\n// Combined auth context resolution (single resolver call)\n// ---------------------------------------------------------------------------\n\nexport interface ResolvedAuthContext {\n /** The full SecurityContext returned by the resolver. */\n security: SecurityContext\n /** Scope ID for scoped entities; undefined for global. */\n scopeId: string | undefined\n /** BehaviorContext to thread into lifecycle hooks. */\n behaviorCtx: BehaviorContext\n}\n\n/**\n * Resolve security context, check permission, derive scope ID, and build\n * BehaviorContext — all from a single resolver invocation.\n *\n * Use instead of calling `assertEntityAccess` + `getScopeId`/`requireScopeForEntity`\n * + manually building a behavior context. Reduces 2-3 resolver calls per write\n * to one, while keeping the granular helpers available for special cases\n * (e.g. cascade delete, which checks permission on a different entity).\n *\n * @param strictScope When true and entity is scoped without a scope in context,\n * throws ForbiddenError. Set on write paths; leave false for\n * reads (callers receive scopeId=undefined and skip filtering).\n * @param enqueueOnCommit Resolved enqueue function from\n * `AdminClientConfig.enqueueOnCommit`. When provided, this\n * is set on `behaviorCtx.enqueueOnCommit` so projection\n * behaviors can fire jobs without importing the queue\n * package. PR C of PLAN-OUTBOX.\n */\nexport async function resolveAuthContext(\n entity: Entity,\n resolver: ContextResolver | undefined,\n action: 'view' | 'create' | 'update' | 'delete',\n options?: {\n strictScope?: boolean\n enqueueOnCommit?: BehaviorContext['enqueueOnCommit']\n },\n): Promise<ResolvedAuthContext> {\n const security = await resolveSecurityContext(resolver)\n\n // Permission check — same logic as assertEntityAccess()\n const role = security.user.groups[0]\n if (!role) {\n throw new ForbiddenError(`User '${security.user.id}' has no role assigned.`)\n }\n if (!security.checker(role, entity.name, action)) {\n throw new ForbiddenError(`Forbidden: role '${role}' cannot ${action} '${entity.name}'`)\n }\n\n // Scope resolution — same logic as getScopeId()/requireScopeForEntity()\n let scopeId: string | undefined\n if (entity.scope && entity.scope !== 'global') {\n scopeId = security.scope?.id\n if (!scopeId && options?.strictScope) {\n throw new ForbiddenError(\n `Entity '${entity.name}' requires scope '${entity.scope}' but no scope is set in context.`,\n )\n }\n }\n\n const behaviorCtx: BehaviorContext = {\n user: {\n id: security.user.id,\n ...(security.user.name !== undefined && { name: security.user.name }),\n ...(security.user.email !== undefined && { email: security.user.email }),\n },\n ...(options?.enqueueOnCommit !== undefined && { enqueueOnCommit: options.enqueueOnCommit }),\n }\n\n return { security, scopeId, behaviorCtx }\n}\n"],"mappings":"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"}
|
package/dist/query/index.d.mts
CHANGED
|
@@ -44,7 +44,7 @@ interface CursorInput {
|
|
|
44
44
|
/** Sort direction — must match the ORDER BY direction. */
|
|
45
45
|
direction: 'asc' | 'desc';
|
|
46
46
|
/** Tie-breaker: last seen entity ID. Required for non-unique sort fields. */
|
|
47
|
-
id?: string;
|
|
47
|
+
id?: string | undefined;
|
|
48
48
|
}
|
|
49
49
|
//#endregion
|
|
50
50
|
//#region src/admin-config.d.ts
|
|
@@ -117,6 +117,23 @@ interface BaseFieldConfig {
|
|
|
117
117
|
translatable?: boolean;
|
|
118
118
|
indexed?: boolean;
|
|
119
119
|
unique?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Marks the field as **system-managed**: stored as a real column, populated
|
|
122
|
+
* by behavior hooks or trusted server-side transitions, but NOT writable
|
|
123
|
+
* through the public `AdminClient.create/update` surface.
|
|
124
|
+
*
|
|
125
|
+
* - Caller-supplied values for internal fields are silently stripped before
|
|
126
|
+
* hooks run (so an HTTP PATCH cannot poison a workflow state).
|
|
127
|
+
* - `beforeCreate` / `beforeUpdate` hooks may still set them (they run after
|
|
128
|
+
* the strip), and the values are preserved through validation.
|
|
129
|
+
* - Trusted server code that needs to write internals directly — e.g.
|
|
130
|
+
* workflow transitions invoked from an authorized admin route — must use
|
|
131
|
+
* `AdminClient.updateInternal()`, which bypasses the strip.
|
|
132
|
+
*
|
|
133
|
+
* Use this flag on any field added by a behavior whose value represents a
|
|
134
|
+
* controlled state machine (e.g. `_workflowStatus`), not user input.
|
|
135
|
+
*/
|
|
136
|
+
internal?: boolean;
|
|
120
137
|
access?: {
|
|
121
138
|
view?: string;
|
|
122
139
|
edit?: string;
|
|
@@ -198,11 +215,76 @@ interface BlocksField extends BaseFieldConfig {
|
|
|
198
215
|
type FieldConfig = IdField | TextField | NumberField | BooleanField | DateField | SelectField | ReferenceField | MediaField | RichTextField | SlugField | JsonField | BlocksField;
|
|
199
216
|
//#endregion
|
|
200
217
|
//#region src/behaviors/types.d.ts
|
|
218
|
+
/**
|
|
219
|
+
* Structural shape of a queue `JobDefinition` as seen by behaviors.
|
|
220
|
+
*
|
|
221
|
+
* The entity package CANNOT import `@murumets-ee/queue` (would form a
|
|
222
|
+
* cycle through `@murumets-ee/core`'s registry — see CLAUDE.md
|
|
223
|
+
* "Package boundaries"). Instead, behaviors receive an `EnqueueOnCommit`
|
|
224
|
+
* function via `BehaviorContext`, and the queue's `JobDefinition<T>`
|
|
225
|
+
* is structurally compatible with this minimal shape. The actual
|
|
226
|
+
* payload validation runs inside the queue when the wrapped resolver
|
|
227
|
+
* forwards the call.
|
|
228
|
+
*/
|
|
229
|
+
interface JobLike {
|
|
230
|
+
readonly name: string;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Resolver shape for `BehaviorContext.enqueueOnCommit`. Synchronous,
|
|
234
|
+
* fire-and-forget — the resolver writes the row in the background and
|
|
235
|
+
* logs failures; behaviors do NOT await.
|
|
236
|
+
*
|
|
237
|
+
* Intended semantics (PLAN-OUTBOX §4.2): the row INSERT participates
|
|
238
|
+
* in the AdminClient operation's transaction — commit makes the job
|
|
239
|
+
* visible to the worker, rollback removes it. As of PR D this is the
|
|
240
|
+
* actual semantics: AdminClient threads its tx into the
|
|
241
|
+
* `EnqueueOnCommitFactory` once per call and passes the result through
|
|
242
|
+
* to behaviors. When the queue plugin is not loaded the field is
|
|
243
|
+
* `undefined` and behaviors no-op (per §6 Q6).
|
|
244
|
+
*/
|
|
245
|
+
type EnqueueOnCommit = (job: JobLike, payload: unknown) => void;
|
|
201
246
|
/**
|
|
202
247
|
* Context passed to behavior hooks. Resolved once per request by AdminClient
|
|
203
248
|
* from its `contextResolver` and forwarded into every hook so behaviors never
|
|
204
249
|
* have to reach into AsyncLocalStorage themselves — that pattern breaks under
|
|
205
250
|
* bundlers (e.g. Turbopack) that duplicate module instances across boundaries.
|
|
251
|
+
*
|
|
252
|
+
* `loadCurrent` returns the *pre-update* entity row. It is eagerly loaded
|
|
253
|
+
* by AdminClient before any hooks fire on the **update** codepath and
|
|
254
|
+
* cached for the rest of that call — so calling it from `beforeUpdate`
|
|
255
|
+
* or `afterUpdate` returns the SAME snapshot regardless of whether a
|
|
256
|
+
* sibling hook touched it. Returns `null` when no entity matches the id
|
|
257
|
+
* (rare — usually means the row was deleted concurrently). On `create`
|
|
258
|
+
* AND `delete` codepaths, `loadCurrent` is undefined: create has no
|
|
259
|
+
* pre-state, and delete hooks receive the entity id directly via their
|
|
260
|
+
* first argument so a separate snapshot is unnecessary.
|
|
261
|
+
*
|
|
262
|
+
* `afterUpdate` always receives the post-update row as its first argument,
|
|
263
|
+
* so the (`row`, `loadCurrent()`) pair gives hooks a complete (after, before)
|
|
264
|
+
* view without per-call Map state. The eager load costs one extra
|
|
265
|
+
* `findById` per update; updates are not a hot path in this codebase, and
|
|
266
|
+
* the consistency win — no foot-gun for hook authors — is worth it.
|
|
267
|
+
*
|
|
268
|
+
* `viaInternal` is `true` when the hook is running on an `AdminClient.updateInternal`
|
|
269
|
+
* call (the trusted server-side path; public PATCH always sets it false).
|
|
270
|
+
* Hooks SHOULD treat this as informational only — the route layer has
|
|
271
|
+
* authorized the *capability*, but the hook is still responsible for
|
|
272
|
+
* enforcing structural invariants. Workflowable, for example, validates
|
|
273
|
+
* the `_workflowStatus` transition table on every update regardless of
|
|
274
|
+
* `viaInternal` so that even a route-layer bug cannot push the workflow
|
|
275
|
+
* row into an illegal state. Use `viaInternal` when a hook genuinely
|
|
276
|
+
* needs to differentiate (e.g. side effects that should only fire when
|
|
277
|
+
* a real user — not the seed loader — initiates the change).
|
|
278
|
+
*
|
|
279
|
+
* `enqueueOnCommit` is the outbox entry point for projection-style hooks
|
|
280
|
+
* (PR C of PLAN-OUTBOX). When wired (the queue plugin is available in
|
|
281
|
+
* the running app), behaviors can enqueue side-effect jobs without
|
|
282
|
+
* importing the queue package directly, keeping entity-as-leaf invariant
|
|
283
|
+
* intact. Synchronous + fire-and-forget — no await needed in hooks.
|
|
284
|
+
* Undefined when the AdminClient was constructed without an
|
|
285
|
+
* `enqueueOnCommit` resolver (e.g. CLI scripts that don't load the
|
|
286
|
+
* queue plugin); behaviors that depend on it must guard with `if
|
|
287
|
+
* (ctx.enqueueOnCommit) { ... }` or document the dependency loudly.
|
|
206
288
|
*/
|
|
207
289
|
interface BehaviorContext {
|
|
208
290
|
user?: {
|
|
@@ -210,6 +292,9 @@ interface BehaviorContext {
|
|
|
210
292
|
name?: string;
|
|
211
293
|
email?: string;
|
|
212
294
|
};
|
|
295
|
+
loadCurrent?: () => Promise<Record<string, unknown> | null>;
|
|
296
|
+
viaInternal?: boolean;
|
|
297
|
+
enqueueOnCommit?: EnqueueOnCommit;
|
|
213
298
|
}
|
|
214
299
|
interface Behavior<F extends Record<string, FieldConfig> = {}> {
|
|
215
300
|
name: string;
|
|
@@ -322,27 +407,27 @@ interface Logger {
|
|
|
322
407
|
interface QueryClientConfig<AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>> {
|
|
323
408
|
entity: Entity<AllFields>;
|
|
324
409
|
db: PostgresJsDatabase;
|
|
325
|
-
logger?: Logger;
|
|
410
|
+
logger?: Logger | undefined;
|
|
326
411
|
/** Optional count cache for COUNT(*) query optimization. */
|
|
327
|
-
countCache?: CountCacheLike;
|
|
412
|
+
countCache?: CountCacheLike | undefined;
|
|
328
413
|
/** Resolves the current request's security context. */
|
|
329
|
-
contextResolver?: ContextResolver;
|
|
414
|
+
contextResolver?: ContextResolver | undefined;
|
|
330
415
|
}
|
|
331
416
|
interface FindByIdOptions {
|
|
332
417
|
select?: string[];
|
|
333
|
-
locale?: string;
|
|
418
|
+
locale?: string | undefined;
|
|
334
419
|
/** Default content locale. For localized blocks, NULL rows (from initial create)
|
|
335
420
|
* are only returned as fallback when locale matches defaultLocale. */
|
|
336
|
-
defaultLocale?: string;
|
|
421
|
+
defaultLocale?: string | undefined;
|
|
337
422
|
}
|
|
338
423
|
interface FindManyOptions {
|
|
339
424
|
where?: SQL | undefined;
|
|
340
|
-
limit?: number;
|
|
341
|
-
offset?: number;
|
|
342
|
-
orderBy?: SQL | SQL[];
|
|
343
|
-
select?: string[];
|
|
344
|
-
locale?: string;
|
|
345
|
-
defaultLocale?: string;
|
|
425
|
+
limit?: number | undefined;
|
|
426
|
+
offset?: number | undefined;
|
|
427
|
+
orderBy?: SQL | SQL[] | undefined;
|
|
428
|
+
select?: string[] | undefined;
|
|
429
|
+
locale?: string | undefined;
|
|
430
|
+
defaultLocale?: string | undefined;
|
|
346
431
|
/**
|
|
347
432
|
* Cursor-based (keyset) pagination. When provided, replaces OFFSET with a
|
|
348
433
|
* WHERE condition for O(1) page access at any depth. The `offset` option
|
|
@@ -350,7 +435,7 @@ interface FindManyOptions {
|
|
|
350
435
|
*
|
|
351
436
|
* The cursor `field` must be a real column on the entity table.
|
|
352
437
|
*/
|
|
353
|
-
cursor?: CursorInput;
|
|
438
|
+
cursor?: CursorInput | undefined;
|
|
354
439
|
}
|
|
355
440
|
interface CountOptions {
|
|
356
441
|
where?: SQL | undefined;
|