@murumets-ee/entity 0.15.3 → 0.16.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.
@@ -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 + 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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"}
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/searchable.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/behaviors/schema-fragment.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 * plus a composite `(status, createdAt)` index for the common admin\n * \"filter by status, sort by createdAt\" list query.\n */\n\nimport { field } from '../fields/builders.js'\nimport type { PublishableFields } from '../types/infer.js'\nimport type { SchemaFragment } from './schema-fragment.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 constraints: (table, target, info): SchemaFragment[] => {\n if (target !== 'main') return []\n const status = table.status\n const createdAt = table.createdAt\n // Defensive: composite only emitted when both columns exist — i.e. when\n // the entity also has `auditable()` (which contributes `createdAt`).\n if (!status || !createdAt) return []\n return [\n {\n kind: 'index',\n name: `idx_${info.entityName}_status_created`,\n on: [status, createdAt],\n },\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 * Searchable behavior — metadata that drives both admin EntityList search\n * and consumer surfaces (ticketing inbox, future commerce admin search,\n * content portal).\n *\n * This commit (PR commit #2) introduces only the **metadata shell**: a\n * `searchable({...})` factory that attaches its configuration to the\n * returned `Behavior` so downstream commits can read it without poking\n * at private state.\n *\n * The DDL contribution (`tsvector` GENERATED column + GIN index for\n * `fts: {...}`) lands in commit #4; the JOIN-mode provider for\n * `includes: [...]` lands in commit #5; the boot-time entity → provider\n * registration lands in commit #6.\n *\n * Cross-package note: this file CANNOT import from `@murumets-ee/search`\n * — that package transitively depends on `@murumets-ee/core` which would\n * pull the entity package out of leaf status (per CLAUDE.md \"Package\n * boundaries\"). The {@link SearchableResultRow} shape is therefore\n * defined locally to be structurally compatible with\n * `@murumets-ee/search`'s `SearchResultRow`; the providers handling\n * dispatch at runtime live in `@murumets-ee/search-postgres`.\n */\n\nimport { sql } from 'drizzle-orm'\nimport { customType } from 'drizzle-orm/pg-core'\nimport type { Entity } from '../define-entity.js'\nimport type { FieldConfig } from '../fields/base.js'\nimport type { SchemaFragment } from './schema-fragment.js'\nimport type { Behavior, BehaviorSchemaInfo } from './types.js'\n\n/**\n * Drizzle custom type for Postgres `tsvector`. Matches the canonical\n * pattern from Drizzle's \"FTS with Generated Columns\" guide.\n */\nexport const tsvector = customType<{ data: string }>({\n dataType: () => 'tsvector',\n})\n\n/** Column name on the main / translations table holding the generated tsvector. */\nexport const SEARCH_TSV_COLUMN = '_search_tsv'\n\n/** setweight() weight letters in declaration order (D5 — first field ranks highest). */\nconst WEIGHT_LETTERS = ['A', 'B', 'C', 'D'] as const\n\nconst SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i\nconst SAFE_LANGUAGE = /^[a-z_][a-z0-9_]*$/i\n\n/**\n * Locally-defined result-row shape. Structurally compatible with\n * `@murumets-ee/search`'s `SearchResultRow` — see file-level note.\n */\nexport interface SearchableResultRow {\n /** Stable id used by the consumer to navigate / select the row. */\n id: string\n /** Headline / title rendered in the result list. */\n label: string\n /** Optional secondary text. */\n description?: string\n /** Optional URL the consumer can link to. */\n url?: string\n /** Provider-specific extras (consumer-typed). */\n data?: Record<string, unknown>\n}\n\n/** Full-text search configuration. */\nexport interface SearchableFts {\n /**\n * Subset of the entity's `fields` that participate in FTS. Order\n * determines `setweight()` (first field → 'A', second → 'B', …).\n */\n readonly fields: readonly string[]\n /**\n * Postgres text-search configuration name. Defaults to `'simple'`\n * (no stemming/stopwords). v1 only wires `'simple'`; locale-specific\n * dictionaries are a future iteration.\n */\n readonly language?: string\n}\n\n/**\n * Cross-entity OR-search target. Allows the parent's `searchable()` to\n * reach into related-entity tsvectors at query time (UNION ALL + GROUP\n * BY shape) without denormalizing into the parent table.\n *\n * Requires the included entity to ALSO have a `searchable({ fts: ... })`\n * declaration — its tsvector is what the parent's provider unions\n * against.\n */\nexport interface SearchableInclude {\n /** Name of the included entity (must have its own `searchable()`). */\n readonly entity: string\n /**\n * Foreign-key **field name** on the included entity that points back\n * to the parent's `id`. Use the JS field name (`ticketId`), not the\n * database column name (`ticket_id`) — `getTable()` returns\n * Drizzle's typed accessor where keys match field names.\n */\n readonly fk: string\n}\n\n/** The metadata a `searchable()` declaration attaches to its `Behavior`. */\nexport interface SearchableConfig {\n /**\n * Fields searched in ILIKE mode AND used to construct the tsvector\n * expression (when `fts` is set). Field names must exist on the entity.\n */\n readonly fields: readonly string[]\n /** Optional FTS configuration. When set, the entity gains a tsvector + GIN. */\n readonly fts?: SearchableFts\n /** Optional cross-entity includes for JOIN-mode search (commit #5). */\n readonly includes?: readonly SearchableInclude[]\n /**\n * Filters applied to every search call unless the caller overrides\n * them. The standard use case is bounding to active rows (e.g.\n * `{ status: ['open', 'pending'] }` to exclude `closed`/`archived`).\n */\n readonly defaultFilters?: Readonly<Record<string, readonly string[]>>\n /**\n * Project an entity row into a `SearchableResultRow`. Per D14 the\n * provider does not synthesize a default — every entity declares its\n * own projection so consumer-visible result shape is explicit.\n */\n readonly projection: (row: Record<string, unknown>) => SearchableResultRow\n}\n\n/** Public options shape accepted by the `searchable()` factory. */\nexport type SearchableOptions = SearchableConfig\n\n/**\n * A {@link Behavior} returned by `searchable()`. Adds a typed `config`\n * property so downstream consumers can read the metadata without\n * casting to `any`.\n */\nexport interface SearchableBehavior extends Behavior {\n readonly name: 'searchable'\n readonly config: SearchableConfig\n}\n\n/**\n * Declare that an entity participates in admin/inbox search.\n *\n * ```ts\n * defineEntity({\n * name: 'ticket',\n * behaviors: [\n * auditable(),\n * searchable({\n * fields: ['subject', 'requesterEmail', 'requesterName'],\n * fts: { fields: ['subject'], language: 'simple' },\n * includes: [{ entity: 'ticket_message', fk: 'ticketId' }],\n * defaultFilters: { status: ['open', 'pending', 'on_hold'] },\n * projection: (row) => ({\n * id: row.id as string,\n * label: row.subject as string,\n * description: row.requesterEmail as string,\n * }),\n * }),\n * ],\n * })\n * ```\n *\n * Adding a field to `fts.fields` on an entity that already shipped FTS\n * requires a manual two-step migration (drop the generated tsvector\n * column, then re-run `pnpm db:migrate`) — Postgres does not allow\n * `ALTER COLUMN` for GENERATED expressions, and drizzle-kit silently\n * ignores the change. v1 is greenfield so this is documented for\n * post-launch operators, not a blocker now.\n */\nexport function searchable(options: SearchableOptions): SearchableBehavior {\n return {\n name: 'searchable',\n config: options,\n columns: (target, info) => {\n const partition = partitionFtsFields(options, info)\n if (partition === null) return {}\n const targetFields = target === 'main' ? partition.main : partition.translatable\n if (targetFields.length === 0) return {}\n return {\n [SEARCH_TSV_COLUMN]: tsvector(SEARCH_TSV_COLUMN)\n .notNull()\n .generatedAlwaysAs(buildTsvectorExpression(partition.language, targetFields)),\n }\n },\n constraints: (table, target, info): SchemaFragment[] => {\n const partition = partitionFtsFields(options, info)\n if (partition === null) return []\n const targetFields = target === 'main' ? partition.main : partition.translatable\n if (targetFields.length === 0) return []\n const tsvCol = table[SEARCH_TSV_COLUMN]\n if (!tsvCol) return []\n const indexName =\n target === 'main'\n ? `idx_${info.entityName}_search_tsv`\n : `idx_${info.entityName}_translations_search_tsv`\n return [\n {\n kind: 'index',\n name: indexName,\n on: [tsvCol],\n using: 'gin',\n },\n ]\n },\n }\n}\n\n/** Field types that can legally participate in `to_tsvector(...)`. */\nconst TSVECTOR_FIELD_TYPES: ReadonlySet<FieldConfig['type']> = new Set([\n 'text',\n 'richtext',\n 'slug',\n])\n\ninterface PartitionedField {\n /** Source field name on the entity. */\n field: string\n /** setweight letter ('A'/'B'/'C'/'D') derived from the original declaration order. */\n weight: string\n}\n\n/**\n * Partition `fts.fields` into the columns destined for the main table\n * vs the translations table. Returns `null` when `fts` is not configured.\n *\n * Each field carries its weight derived from the **original declaration\n * index** — NOT from its position within the resulting partition.\n * This keeps the user's \"first field ranks highest globally\" promise\n * intact even when fields are split across main/translations. e.g. for\n * `fts.fields: ['slug', 'title', 'body']` with slug non-translatable\n * and title/body translatable, the main tsvector gets `slug` weighted\n * 'A' and the translations tsvector gets `title` 'B' + `body` 'C'.\n *\n * Throws at schema-build time on:\n *\n * - an `fts.field` that does not exist on the entity\n * - an `fts.field` whose declared type cannot be cast to `text`\n * (`to_tsvector` only accepts text — silently casting a Date /\n * number / boolean produces \"cannot cast\" at migrate time)\n * - an unsafe field/language identifier (defense in depth — the\n * generated tsvector expression inlines these as SQL identifiers)\n * - more than four `fts.fields` (setweight only supports A/B/C/D)\n */\nfunction partitionFtsFields(\n options: SearchableOptions,\n info: BehaviorSchemaInfo,\n): {\n language: string\n main: readonly PartitionedField[]\n translatable: readonly PartitionedField[]\n} | null {\n if (!options.fts) return null\n const language = options.fts.language ?? 'simple'\n if (!SAFE_LANGUAGE.test(language)) {\n throw new Error(\n `searchable({ fts: { language: ${JSON.stringify(language)} } }) on \"${info.entityName}\" — language must match ${SAFE_LANGUAGE} (alphanumeric + underscore)`,\n )\n }\n const fields = options.fts.fields\n if (fields.length === 0) {\n throw new Error(\n `searchable({ fts: { fields: [] } }) on \"${info.entityName}\" — at least one field required`,\n )\n }\n if (fields.length > WEIGHT_LETTERS.length) {\n throw new Error(\n `searchable({ fts: { fields: [...${fields.length}] } }) on \"${info.entityName}\" — at most ${WEIGHT_LETTERS.length} fts fields are supported (setweight A/B/C/D)`,\n )\n }\n const main: PartitionedField[] = []\n const translatable: PartitionedField[] = []\n for (let index = 0; index < fields.length; index++) {\n const field = fields[index]\n if (field === undefined) continue\n if (!SAFE_IDENTIFIER.test(field)) {\n throw new Error(\n `searchable({ fts: { fields: [..., ${JSON.stringify(field)}] } }) on \"${info.entityName}\" — field names must be valid SQL identifiers`,\n )\n }\n const config = info.allFields[field] as FieldConfig | undefined\n if (!config) {\n throw new Error(\n `searchable({ fts: { fields: [..., ${JSON.stringify(field)}] } }) on \"${info.entityName}\" — field is not declared on the entity`,\n )\n }\n if (!TSVECTOR_FIELD_TYPES.has(config.type)) {\n throw new Error(\n `searchable({ fts: { fields: [..., ${JSON.stringify(field)}] } }) on \"${info.entityName}\" — field type \"${config.type}\" cannot be indexed by to_tsvector (only text, richtext, slug are supported)`,\n )\n }\n const weight = WEIGHT_LETTERS[index] ?? 'D'\n const entry: PartitionedField = { field, weight }\n if (config.translatable) translatable.push(entry)\n else main.push(entry)\n }\n return { language, main, translatable }\n}\n\n/**\n * Compose the GENERATED ALWAYS AS expression. Uses `sql.raw` because:\n *\n * - Generated-column DDL cannot contain bind parameters — Postgres\n * requires immutable expressions, so language + column references\n * must be inlined as SQL identifiers / literals.\n * - The two inputs (`language`, field names) are validated against\n * `SAFE_LANGUAGE` / `SAFE_IDENTIFIER` before reaching here. The\n * factory throws at schema-build time on anything outside\n * `^[a-z_][a-z0-9_]*$`, so the raw SQL contains only known-safe\n * tokens. No SQL-injection vector — the inputs are developer-time\n * config strings, not user data, AND they are validated.\n */\nfunction buildTsvectorExpression(language: string, parts: readonly PartitionedField[]) {\n const fragments = parts.map(\n ({ field, weight }) =>\n `setweight(to_tsvector('${language}', coalesce(\"${field}\", '')), '${weight}')`,\n )\n return sql.raw(fragments.join(' || '))\n}\n\n/**\n * Runtime type predicate — narrows a `Behavior` to {@link SearchableBehavior}.\n * Guards against accidental matches where another behavior happens to\n * also be named `'searchable'` (defense in depth — shouldn't happen in\n * practice).\n */\nexport function isSearchableBehavior(b: Behavior): b is SearchableBehavior {\n if (b.name !== 'searchable') return false\n if (!('config' in b)) return false\n const extended = b as { config?: unknown }\n if (!extended.config || typeof extended.config !== 'object') return false\n const config = extended.config as { fields?: unknown; projection?: unknown }\n return Array.isArray(config.fields) && typeof config.projection === 'function'\n}\n\n/**\n * Read the `searchable()` config from an entity. Returns `undefined`\n * when the entity does not declare `searchable()`.\n *\n * Used by the admin search bootstrap (commit #6) to build a provider\n * per searchable entity, and by the EntityList toolbar to know whether\n * to enable the search input.\n */\nexport function getSearchableConfig(entity: Entity): SearchableConfig | undefined {\n const found = entity.behaviors?.find(isSearchableBehavior)\n return found?.config\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 { searchable } from './searchable.js'\nimport { sluggable } from './sluggable.js'\nimport { timestamped } from './timestamped.js'\n\nexport {\n auditable,\n hierarchical,\n publishable,\n revisionable,\n searchable,\n sluggable,\n timestamped,\n}\nexport type { SluggableOptions } from './sluggable.js'\nexport { slugify } from './sluggable.js'\nexport type { Behavior, BehaviorContext, BehaviorFactory } from './types.js'\nexport type { SchemaFragment, SchemaTarget } from './schema-fragment.js'\nexport type {\n SearchableBehavior,\n SearchableConfig,\n SearchableFts,\n SearchableInclude,\n SearchableOptions,\n SearchableResultRow,\n} from './searchable.js'\nexport { getSearchableConfig, isSearchableBehavior } from './searchable.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 searchable,\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 * Behavior-contributed schema fragments.\n *\n * Behaviors can extend an entity's schema beyond just adding fields/hooks\n * via two optional callbacks on `Behavior`:\n *\n * - `columns(target, entityName)` — contributes extra columns to the\n * main table or translation table.\n * Runs BEFORE the Drizzle `pgTable()`\n * constructor — required so that\n * GENERATED columns (which must be\n * declared as columns, not\n * constraints) can participate.\n * - `constraints(table, target, entityName)` — contributes table-level\n * constraints (indexes, uniques,\n * checks). Runs INSIDE the\n * `pgTable(name, cols, callback)`\n * builder callback once columns are\n * resolved.\n *\n * The two callbacks are separated because Drizzle's `pgTable` API forces\n * the split — generated columns belong in the column map, constraints\n * belong in the constraint-builder callback.\n *\n * `target` discriminates between the main entity table and its\n * `<entity>_translations` companion so a single behavior can contribute\n * different fragments to each (used by `searchable()` for translatable FTS).\n */\n\nimport type { SQL } from 'drizzle-orm'\nimport { check, index, unique } from 'drizzle-orm/pg-core'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\n\n/** Which table a behavior fragment targets. */\nexport type SchemaTarget = 'main' | 'translations'\n\n/**\n * A constraint contributed by a behavior. The union is intentionally\n * closed — there is NO `kind: 'raw'` escape hatch. If a future behavior\n * needs a new constraint shape, add a typed variant to this union rather\n * than reaching for raw SQL.\n */\nexport type SchemaFragment =\n | {\n kind: 'index'\n name: string\n on: PgColumn[]\n /** Defaults to btree. Pass `'gin'`/`'gist'` for FTS / vector indexes. */\n using?: 'btree' | 'gin' | 'gist' | 'hash' | 'brin'\n }\n | {\n kind: 'uniqueConstraint'\n /**\n * Required even though Drizzle's `unique()` accepts an unnamed\n * constraint — the schema generator uses `name` as the\n * collision-detection key across behaviors.\n */\n name: string\n on: PgColumn[]\n }\n | {\n kind: 'checkConstraint'\n name: string\n expression: SQL\n }\n\n/**\n * Convert a `SchemaFragment` into the corresponding Drizzle constraint\n * builder. Used by `schema-generator` inside the `pgTable` callback.\n */\nexport function schemaFragmentToBuilder(fragment: SchemaFragment) {\n switch (fragment.kind) {\n case 'index': {\n if (fragment.on.length === 0) {\n throw new Error(`Index \"${fragment.name}\" has no columns`)\n }\n const head = fragment.on[0]\n if (!head) {\n throw new Error(`Index \"${fragment.name}\" first column is undefined`)\n }\n const tail = fragment.on.slice(1)\n if (fragment.using && fragment.using !== 'btree') {\n return index(fragment.name).using(fragment.using, head, ...tail)\n }\n return index(fragment.name).on(head, ...tail)\n }\n case 'uniqueConstraint': {\n if (fragment.on.length === 0) {\n throw new Error(`Unique constraint \"${fragment.name}\" has no columns`)\n }\n const head = fragment.on[0]\n if (!head) {\n throw new Error(`Unique constraint \"${fragment.name}\" first column is undefined`)\n }\n const tail = fragment.on.slice(1)\n return unique(fragment.name).on(head, ...tail)\n }\n case 'checkConstraint':\n return check(fragment.name, fragment.expression)\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 { PgColumn, PgColumnBuilderBase, PgTable } from 'drizzle-orm/pg-core'\nimport {\n boolean,\n doublePrecision,\n getTableConfig,\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 {\n type SchemaFragment,\n type SchemaTarget,\n schemaFragmentToBuilder,\n} from './behaviors/schema-fragment.js'\nimport type { Entity } from './define-entity.js'\nimport type { FieldConfig } from './fields/base.js'\nimport { entityRefs } from './refs/schema.js'\n\n/**\n * Collect a single behavior's contributions to a given target table.\n *\n * The two-phase shape (`Behavior.columns` runs before `pgTable()`,\n * `Behavior.constraints` runs inside the builder callback) is forced by\n * Drizzle's API — generated columns must be declared as columns, not\n * constraints. This helper centralises the iteration + collision checks\n * so `generateSchema` and `generateTranslationSchema` share one\n * implementation.\n */\nfunction collectBehaviorContributions(\n entity: Entity,\n target: SchemaTarget,\n): {\n columns: Record<string, PgColumnBuilderBase>\n constraintProducers: Array<(table: Record<string, PgColumn>) => SchemaFragment[]>\n} {\n const columns: Record<string, PgColumnBuilderBase> = {}\n const constraintProducers: Array<(table: Record<string, PgColumn>) => SchemaFragment[]> = []\n const info = {\n entityName: entity.name,\n allFields: entity.allFields as Readonly<Record<string, FieldConfig>>,\n }\n\n for (const behavior of entity.behaviors ?? []) {\n if (behavior.columns) {\n const contributed = behavior.columns(target, info)\n for (const [name, builder] of Object.entries(contributed)) {\n if (name in columns) {\n throw new Error(\n `Two behaviors contribute column \"${name}\" on entity \"${entity.name}\" (target=${target}) — last contributor was \"${behavior.name}\"`,\n )\n }\n columns[name] = builder\n }\n }\n if (behavior.constraints) {\n const produce = behavior.constraints\n constraintProducers.push((table) => produce(table, target, info))\n }\n }\n\n return { columns, constraintProducers }\n}\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 { columns: behaviorColumns, constraintProducers } = collectBehaviorContributions(\n entity,\n 'main',\n )\n\n // Merge entity-field columns with behavior-contributed columns. Collisions\n // between the two surfaces (behavior trying to overwrite an entity field)\n // are caught here.\n for (const [name, col] of Object.entries(behaviorColumns)) {\n if (name in columns) {\n throw new Error(\n `Behavior on entity \"${entity.name}\" contributes column \"${name}\" that collides with an entity field`,\n )\n }\n columns[name] = col\n }\n\n const needsCallback = indexedFields.length > 0 || constraintProducers.length > 0\n if (!needsCallback) {\n return pgTable(tableName, columns)\n }\n\n const table = pgTable(tableName, columns, (table) => {\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns from pgTable callback\n const constraints: Record<string, any> = {}\n const usedNames = new Set<string>()\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 const name = `idx_${tableName}_${fieldName}`\n constraints[name] = index(name).on(col)\n usedNames.add(name)\n }\n\n // biome-ignore lint/suspicious/noExplicitAny: drizzle table shape varies by column set\n const tableAsColumns = table as unknown as Record<string, PgColumn>\n for (const produce of constraintProducers) {\n const fragments = produce(tableAsColumns)\n for (const fragment of fragments) {\n if (usedNames.has(fragment.name)) {\n throw new Error(\n `Behavior on entity \"${entity.name}\" contributes constraint \"${fragment.name}\" that collides with another constraint`,\n )\n }\n usedNames.add(fragment.name)\n constraints[fragment.name] = schemaFragmentToBuilder(fragment)\n }\n }\n\n return constraints\n })\n // Force the deferred extra-config callback to evaluate now so behavior\n // collision checks (column collisions, constraint-name collisions)\n // throw at schema-build time rather than later during introspection.\n // Drizzle's `pgTable(name, cols, builder)` stores the builder for lazy\n // evaluation by `getTableConfig` (and the migrator); a quiet collision\n // would otherwise only surface at `pnpm db:migrate` time.\n getTableConfig(table)\n return table\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 const { columns: behaviorColumns, constraintProducers } = collectBehaviorContributions(\n entity,\n 'translations',\n )\n for (const [name, col] of Object.entries(behaviorColumns)) {\n if (name in columns) {\n throw new Error(\n `Behavior on entity \"${entity.name}\" contributes translation-table column \"${name}\" that collides with an existing column`,\n )\n }\n columns[name] = col\n }\n\n const table = 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 // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns from pgTable callback\n const constraints: Record<string, any> = {\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 ? { uniqueSlugLocale: unique().on(slugT, localeT) } : {}),\n }\n const usedNames = new Set<string>(Object.keys(constraints))\n\n // biome-ignore lint/suspicious/noExplicitAny: drizzle table shape varies by column set\n const tableAsColumns = table as unknown as Record<string, PgColumn>\n for (const produce of constraintProducers) {\n const fragments = produce(tableAsColumns)\n for (const fragment of fragments) {\n if (usedNames.has(fragment.name)) {\n throw new Error(\n `Behavior on entity \"${entity.name}\" translation table contributes constraint \"${fragment.name}\" that collides`,\n )\n }\n usedNames.add(fragment.name)\n constraints[fragment.name] = schemaFragmentToBuilder(fragment)\n }\n }\n return constraints\n })\n // Eager evaluation of behavior contributions — same rationale as generateSchema.\n getTableConfig(table)\n return table\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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"}
@@ -1,5 +1,7 @@
1
1
  import { SQL } from "drizzle-orm";
2
2
  import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import { PgColumn, PgColumnBuilderBase } from "drizzle-orm/pg-core";
4
+
3
5
  //#region src/count-cache.d.ts
4
6
  /**
5
7
  * In-memory TTL cache for COUNT(*) query results.
@@ -214,6 +216,35 @@ interface BlocksField extends BaseFieldConfig {
214
216
  }
215
217
  type FieldConfig = IdField | TextField | NumberField | BooleanField | DateField | SelectField | ReferenceField | MediaField | RichTextField | SlugField | JsonField | BlocksField;
216
218
  //#endregion
219
+ //#region src/behaviors/schema-fragment.d.ts
220
+ /** Which table a behavior fragment targets. */
221
+ type SchemaTarget = 'main' | 'translations';
222
+ /**
223
+ * A constraint contributed by a behavior. The union is intentionally
224
+ * closed — there is NO `kind: 'raw'` escape hatch. If a future behavior
225
+ * needs a new constraint shape, add a typed variant to this union rather
226
+ * than reaching for raw SQL.
227
+ */
228
+ type SchemaFragment = {
229
+ kind: 'index';
230
+ name: string;
231
+ on: PgColumn[]; /** Defaults to btree. Pass `'gin'`/`'gist'` for FTS / vector indexes. */
232
+ using?: 'btree' | 'gin' | 'gist' | 'hash' | 'brin';
233
+ } | {
234
+ kind: 'uniqueConstraint';
235
+ /**
236
+ * Required even though Drizzle's `unique()` accepts an unnamed
237
+ * constraint — the schema generator uses `name` as the
238
+ * collision-detection key across behaviors.
239
+ */
240
+ name: string;
241
+ on: PgColumn[];
242
+ } | {
243
+ kind: 'checkConstraint';
244
+ name: string;
245
+ expression: SQL;
246
+ };
247
+ //#endregion
217
248
  //#region src/behaviors/types.d.ts
218
249
  /**
219
250
  * Structural shape of a queue `JobDefinition` as seen by behaviors.
@@ -307,6 +338,40 @@ interface Behavior<F extends Record<string, FieldConfig> = {}> {
307
338
  beforeDelete?: (id: string, ctx?: BehaviorContext) => Promise<void>;
308
339
  afterDelete?: (id: string, ctx?: BehaviorContext) => Promise<void>;
309
340
  };
341
+ /**
342
+ * Contribute extra columns to the entity's main table or `<entity>_translations` table.
343
+ * Runs BEFORE the Drizzle `pgTable()` call so contributions (including GENERATED
344
+ * columns) participate in the column map. Returns an empty object when the behavior
345
+ * does not contribute to the requested `target`.
346
+ *
347
+ * `info.allFields` is the post-behaviors-merge field map (i.e. user fields plus
348
+ * any fields contributed by earlier behaviors). Behaviors like `searchable()`
349
+ * use it to decide whether to put the column on `'main'` or `'translations'`
350
+ * based on each referenced field's `translatable` flag.
351
+ *
352
+ * Collisions with entity fields or other behaviors' contributions throw at
353
+ * schema-build time — see `schema-generator.ts:collectBehaviorContributions`.
354
+ */
355
+ columns?: (target: SchemaTarget, info: BehaviorSchemaInfo) => Record<string, PgColumnBuilderBase>;
356
+ /**
357
+ * Contribute table-level constraints (indexes, uniques, checks) to the entity's
358
+ * main table or `<entity>_translations` table. Runs INSIDE the `pgTable` builder
359
+ * callback once columns are resolved. Returns an empty array when the behavior
360
+ * does not contribute to the requested `target`.
361
+ *
362
+ * Constraint names must be globally unique within the table; collisions throw.
363
+ */
364
+ constraints?: (table: Record<string, PgColumn>, target: SchemaTarget, info: BehaviorSchemaInfo) => SchemaFragment[];
365
+ }
366
+ /**
367
+ * Subset of `Entity` passed to behavior `columns` / `constraints` callbacks.
368
+ * Structurally typed so `behaviors/types.ts` does not need to import the full
369
+ * `Entity` type from `../define-entity.js` (which would create the well-known
370
+ * types-↔-define-entity dance inside the package).
371
+ */
372
+ interface BehaviorSchemaInfo {
373
+ readonly entityName: string;
374
+ readonly allFields: Readonly<Record<string, FieldConfig>>;
310
375
  }
311
376
  //#endregion
312
377
  //#region src/define-entity.d.ts