@murumets-ee/entity 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js DELETED
@@ -1,64 +0,0 @@
1
- import {sql,lt,gt,or,and,eq}from'drizzle-orm';import {uuid,pgTable,index,varchar,unique,jsonb,integer,text,timestamp,boolean,doublePrecision}from'drizzle-orm/pg-core';var s={id:t=>({type:"id",required:true,indexed:true,...t}),text:t=>({type:"text",...t}),number:t=>({type:"number",...t}),boolean:t=>({type:"boolean",default:false,...t}),date:t=>({type:"date",...t}),select:t=>({type:"select",...t}),reference:t=>({type:"reference",cardinality:"one",onDelete:"set-null",...t}),media:t=>({type:"media",...t}),richtext:t=>({type:"richtext",...t}),slug:t=>({type:"slug",unique:true,indexed:true,...t}),json:t=>({type:"json",...t}),blocks:t=>({type:"blocks",...t})};async function R(){try{return (await import(["@murumets-ee","core"].join("/"))).getCurrentUser()?.id}catch{return}}function y(){return {name:"auditable",fields:{createdBy:s.text(),updatedBy:s.text(),createdAt:s.date({indexed:true}),updatedAt:s.date({indexed:true})},hooks:{beforeCreate:async t=>{t.createdAt=new Date,t.updatedAt=new Date;let e=await R();return e&&(t.createdBy=e,t.updatedBy=e),t},beforeUpdate:async(t,e)=>{e.updatedAt=new Date;let r=await R();return r&&(e.updatedBy=r),e}}}}function h(t){return {name:"hierarchical",fields:{parentId:s.reference({entity:"_self",required:false}),path:s.text({indexed:true,maxLength:2048}),depth:s.number({integer:true,default:0,indexed:true})},hooks:{beforeCreate:async e=>(e.parentId||(e.depth=0),e)}}}function g(){return {name:"publishable",fields:{status:s.select({options:["draft","published"],default:"draft",indexed:true}),publishedAt:s.date({indexed:true})},hooks:{beforeUpdate:async(t,e)=>(e.status==="published"&&!e.publishedAt&&(e.publishedAt=new Date),e)}}}function x(){return {name:"revisionable",fields:{_version:s.number({required:true,default:1,integer:true})},hooks:{beforeCreate:async t=>(t._version=1,t),beforeUpdate:async(t,e)=>(e._version=(Number(e._version)||0)+1,e)}}}function f(t){return t.toLowerCase().trim().replace(/[^\w\s-]/g,"").replace(/[\s_-]+/g,"-").replace(/^-+|-+$/g,"")}function F(t,e){return {name:"sluggable",fields:{slug:s.slug({from:t,...e?.translatable?{translatable:true}:{}})},hooks:{beforeCreate:async r=>(!r.slug&&r[t]&&(r.slug=f(String(r[t]))),r),beforeUpdate:async(r,o)=>(o[t]&&!o.slug&&(o.slug=f(String(o[t]))),o)}}}function v(){return {name:"timestamped",fields:{createdAt:s.date({indexed:true}),updatedAt:s.date({indexed:true})},hooks:{beforeCreate:async t=>(t.createdAt=new Date,t.updatedAt=new Date,t),beforeUpdate:async(t,e)=>(e.updatedAt=new Date,e)}}}var D={publishable:g,auditable:y,sluggable:F,revisionable:x,hierarchical:h,timestamped:v};var _=class{cache=new Map;ttlMs;constructor(e=5e3){this.ttlMs=e;}get(e){let r=this.cache.get(e);if(!r||Date.now()>r.expiresAt){r&&this.cache.delete(e);return}return r.count}set(e,r){this.cache.set(e,{count:r,expiresAt:Date.now()+this.ttlMs});}invalidate(e){for(let r of this.cache.keys())r.startsWith(e)&&this.cache.delete(r);}prune(){let e=Date.now();for(let[r,o]of this.cache.entries())e>o.expiresAt&&this.cache.delete(r);}clear(){this.cache.clear();}get size(){return this.cache.size}};async function P(t,e){let r=await t.execute(sql`SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = ${e}`),n=(Array.isArray(r)?r:r.rows??[])[0],i=Number(n?.estimate??0);return i>0?i:0}var M=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function J(t,e,r){let o={field:e,value:t[e],direction:r,id:t.id};return btoa(JSON.stringify(o))}function V(t){try{let e=atob(t),r=JSON.parse(e);if(typeof r!="object"||r===null)return null;let o=r;return typeof o.field!="string"||!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(o.field)||typeof o.value!="string"&&typeof o.value!="number"||o.direction!=="asc"&&o.direction!=="desc"||o.id!==void 0&&(typeof o.id!="string"||!M.test(o.id))?null:{field:o.field,value:o.value,direction:o.direction,id:o.id}}catch{return null}}function W(t,e){let r=t[e.field];if(!r)return null;let n=e.direction==="desc"?lt:gt,i=n(r,e.value);if(!e.id)return i;let a=t.id;if(!a)return i;let l=n(a,e.id);return or(i,and(eq(r,e.value),l))}function Q(t){let e={},r={};for(let n of t.behaviors||[]){if(n.fields)for(let[i,a]of Object.entries(n.fields)){if(e[i]){console.warn(`Field '${i}' from behavior '${n.name}' conflicts with existing field. Skipping.`);continue}e[i]=a;}if(n.hooks)for(let[i,a]of Object.entries(n.hooks)){let l=i;if(a){let c=r[l];if(c){let S=c,N=a,A=async(...m)=>{let E=await S(...m);if(E!==void 0){let b=[...m];return b[b.length-1]=E,await N(...b)}return await N(...m)};r[l]=A;}else r[l]=a;}}}let o={id:s.id(),...e,...t.fields};for(let[,n]of Object.entries(o))n.type==="slug"&&!n.translatable&&o[n.from]?.translatable&&(n.translatable=true);return {...t,allFields:o,hooks:r}}var C=class extends Error{entityName;entityId;usages;constructor(e,r,o){let n=o.length;super(`Cannot delete ${e} '${r}': referenced by ${n} other entit${n===1?"y":"ies"}`),this.name="ReferencedEntityError",this.entityName=e,this.entityId=r,this.usages=o;}};function w(t,e,r){let o=r?.nullable??false;switch(e.type){case "id":return uuid(t).primaryKey().defaultRandom();case "text":if(e.maxLength&&e.maxLength<=255){let n=varchar(t,{length:e.maxLength});return e.unique&&(n=n.unique()),!o&&e.required&&(n=n.notNull()),!o&&e.default!==void 0&&(n=n.default(e.default)),n}else {let n=text(t);return e.unique&&(n=n.unique()),!o&&e.required&&(n=n.notNull()),!o&&e.default!==void 0&&(n=n.default(e.default)),n}case "number":{let n=e.integer?integer(t):doublePrecision(t);return !o&&e.required&&(n=n.notNull()),!o&&e.default!==void 0&&(n=n.default(e.default)),n}case "boolean":{let n=boolean(t);if(!o&&e.required&&(n=n.notNull()),!o){let i=e.default!==void 0?e.default:false;n=n.default(i);}return n}case "date":{let n=timestamp(t,{withTimezone:true});return !o&&e.required&&(n=n.notNull()),!o&&e.default!==void 0&&(n=n.default(e.default)),n}case "select":{let n=varchar(t,{length:100});return !o&&e.required&&(n=n.notNull()),!o&&e.default!==void 0&&(n=n.default(e.default)),n}case "reference":{if(e.cardinality==="many")return uuid(t).array();let n=uuid(t);return !o&&e.required&&(n=n.notNull()),n}case "media":{let n=uuid(t);return !o&&e.required&&(n=n.notNull()),n}case "slug":{let n=varchar(t,{length:255});return e.unique&&!o&&(n=n.unique()),!o&&e.required&&(n=n.notNull()),n}case "richtext":{let n=text(t);return !o&&e.required&&(n=n.notNull()),n}case "json":return jsonb(t);default:return text(t)}}function q(t,e,r){let o=r?.nullable??false;switch(e.type){case "id":return "id: uuid('id').primaryKey().defaultRandom()";case "text":{let n=e.maxLength||255,i=n<=255?`varchar('${t}', { length: ${n} })`:`text('${t}')`;return e.unique&&(i+=".unique()"),!o&&e.required&&(i+=".notNull()"),!o&&e.default&&(i+=`.default('${e.default}')`),`${t}: ${i}`}case "number":{let n=e.integer?`integer('${t}')`:`doublePrecision('${t}')`;return !o&&e.required&&(n+=".notNull()"),!o&&e.default!==void 0&&(n+=`.default(${e.default})`),`${t}: ${n}`}case "boolean":{if(o)return `${t}: boolean('${t}')`;let n=e.default??false;return `${t}: boolean('${t}').default(${n})`}case "date":{let n=`timestamp('${t}', { withTimezone: true })`;return !o&&e.required&&(n+=".notNull()"),`${t}: ${n}`}case "reference":{if(e.cardinality==="many")return `${t}: uuid('${t}').array()`;let n=`uuid('${t}')`;return !o&&e.required&&(n+=".notNull()"),`${t}: ${n}`}case "media":{let n=`uuid('${t}')`;return !o&&e.required&&(n+=".notNull()"),`${t}: ${n}`}case "slug":{let n=`varchar('${t}', { length: 255 })`;return e.unique&&!o&&(n+=".unique()"),!o&&e.required&&(n+=".notNull()"),`${t}: ${n}`}case "select":{let n=`varchar('${t}', { length: 100 })`;return !o&&e.required&&(n+=".notNull()"),!o&&e.default&&(n+=`.default('${e.default}')`),`${t}: ${n}`}case "richtext":{let n=`text('${t}')`;return !o&&e.required&&(n+=".notNull()"),`${t}: ${n}`}case "json":return `${t}: jsonb('${t}')`;default:return `${t}: text('${t}')`}}function Y(t){let e=t.name,r={};for(let[i,a]of Object.entries(t.allFields))a.type!=="blocks"&&(r[i]=w(i,a));(t.scope==="team"||t.scope==="user")&&(r._scopeId=uuid("_scope_id"));let o=Object.entries(t.allFields).filter(([i,a])=>a.type!=="blocks"&&a.type!=="id"&&a.indexed&&!a.unique),n=t.behaviors?.some(i=>i.name==="publishable")??false;return o.length===0&&!n?pgTable(e,r):pgTable(e,r,i=>{let a={};for(let[l]of o)a[`idx_${e}_${l}`]=index(`idx_${e}_${l}`).on(i[l]);return n&&i.status&&i.createdAt&&(a[`idx_${e}_status_created`]=index(`idx_${e}_status_created`).on(i.status,i.createdAt)),a})}function ee(t){let e=t.name,r=[],o=Object.entries(t.allFields).filter(([a,l])=>l.type!=="blocks"&&l.type!=="id"&&l.indexed&&!l.unique),n=t.behaviors?.some(a=>a.name==="publishable")??false,i=o.length>0||n;r.push(`export const ${e} = pgTable('${e}', {`);for(let[a,l]of Object.entries(t.allFields)){if(l.type==="blocks")continue;let c=q(a,l);r.push(` ${c},`);}if((t.scope==="team"||t.scope==="user")&&r.push(" _scopeId: uuid('_scope_id'),"),i){r.push("}, (table) => ({");for(let[a]of o)r.push(` idx_${e}_${a}: index('idx_${e}_${a}').on(table.${a}),`);n&&r.push(` idx_${e}_status_created: index('idx_${e}_status_created').on(table.status, table.createdAt),`),r.push("}))");}else r.push(")");return r.join(`
2
- `)}function te(t){let e=Object.entries(t.allFields).filter(([i,a])=>a.translatable);if(e.length===0)return null;let r=`${t.name}_translations`,o={id:uuid("id").primaryKey().defaultRandom(),entityId:uuid("entity_id").notNull(),locale:varchar("locale",{length:10}).notNull()},n=e.some(([i,a])=>a.type==="slug");for(let[i,a]of e)o[i]=w(i,a,{nullable:true});return pgTable(r,o,i=>({uniqueEntityLocale:unique().on(i.entityId,i.locale),...n&&i.slug?{uniqueSlugLocale:unique().on(i.slug,i.locale)}:{}}))}function ne(t){let e=Object.entries(t.allFields).filter(([a,l])=>l.translatable);if(e.length===0)return null;let r=`${t.name}_translations`,o=t.name,n=[];n.push(`export const ${r} = pgTable('${r}', {`),n.push(" id: uuid('id').primaryKey().defaultRandom(),"),n.push(` entityId: uuid('entity_id').notNull().references(() => ${o}.id, { onDelete: 'cascade' }),`),n.push(" locale: varchar('locale', { length: 10 }).notNull(),");for(let[a,l]of e){let c=q(a,l,{nullable:true});n.push(` ${c},`);}let i=e.some(([a,l])=>l.type==="slug");return n.push("}, (table) => ({"),n.push(" uniqueEntityLocale: unique().on(table.entityId, table.locale), // One translation per locale"),i&&n.push(" uniqueSlugLocale: unique().on(table.slug, table.locale), // Per-locale slug uniqueness"),n.push("}))"),n.join(`
3
- `)}function I(t){return Object.values(t.allFields).some(e=>e.type==="blocks")}function re(t){if(!I(t))return null;let e=`${t.name}_layout`;return pgTable(e,{id:uuid("id").primaryKey().defaultRandom(),entityId:uuid("entity_id").notNull(),fieldName:varchar("field_name",{length:100}).notNull(),blockType:varchar("block_type",{length:100}).notNull(),sortOrder:integer("sort_order").notNull().default(0),data:jsonb("data"),locale:varchar("locale",{length:10})},r=>({idx_entity_locale_sort:index(`idx_${e}_entity_locale_sort`).on(r.entityId,r.locale,r.sortOrder)}))}function oe(t){return t.behaviors?.some(e=>e.name==="versionable")??false}function ie(t){if(!(t.behaviors?.some(o=>o.name==="publishable")??false))return false;let r=t.allFields;return Object.values(r).some(o=>o.translatable)}function ae(t){let e=t.name,r=`${e}_locale_status`;return `export const ${r} = pgTable('${r}', {
4
- id: uuid('id').primaryKey().defaultRandom(),
5
- entityId: uuid('entity_id').notNull().references(() => ${e}.id, { onDelete: 'cascade' }),
6
- locale: varchar('locale', { length: 10 }).notNull(),
7
- status: varchar('status', { length: 20 }).notNull().default('draft'),
8
- publishedAt: timestamp('published_at', { withTimezone: true }),
9
- }, (table) => ({
10
- uniqueEntityLocale: unique().on(table.entityId, table.locale),
11
- }))`}function le(t){return t.behaviors?.some(e=>e.name==="publishable")??false}function se(t){let e=t.name,r=`${e}_drafts`;return `export const ${r} = pgTable('${r}', {
12
- id: uuid('id').primaryKey().defaultRandom(),
13
- entityId: uuid('entity_id').notNull().references(() => ${e}.id, { onDelete: 'cascade' }),
14
- locale: varchar('locale', { length: 10 }).notNull().default('_'),
15
- data: jsonb('data').notNull(),
16
- createdBy: varchar('created_by', { length: 255 }).notNull(),
17
- createdByName: varchar('created_by_name', { length: 255 }),
18
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
19
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
20
- }, (table) => ({
21
- uniqueEntityLocale: unique().on(table.entityId, table.locale),
22
- }))`}function ue(){return `export const toolkit_content_locks = pgTable('toolkit_content_locks', {
23
- id: uuid('id').primaryKey().defaultRandom(),
24
- entityType: varchar('entity_type', { length: 100 }).notNull(),
25
- entityId: varchar('entity_id', { length: 255 }).notNull(),
26
- locale: varchar('locale', { length: 10 }).notNull().default('_'),
27
- lockedBy: varchar('locked_by', { length: 255 }).notNull(),
28
- lockedByName: varchar('locked_by_name', { length: 255 }),
29
- lockedAt: timestamp('locked_at', { withTimezone: true }).notNull().defaultNow(),
30
- expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
31
- }, (table) => ({
32
- uniqueEntityLock: unique().on(table.entityType, table.entityId, table.locale),
33
- }))`}function de(t){let e=t.allFields;return Object.values(e).some(r=>r.type!=="blocks"||r.localized?false:r.blocks?.some(o=>Object.values(o.fields).some(n=>n.translatable)))}function ce(t){let e=t.name,r=`${e}_layout`;return `export const ${r} = pgTable('${r}', {
34
- id: uuid('id').primaryKey().defaultRandom(),
35
- entityId: uuid('entity_id').notNull().references(() => ${e}.id, { onDelete: 'cascade' }),
36
- fieldName: varchar('field_name', { length: 100 }).notNull(),
37
- blockType: varchar('block_type', { length: 100 }).notNull(),
38
- sortOrder: integer('sort_order').notNull().default(0),
39
- data: jsonb('data'),
40
- locale: varchar('locale', { length: 10 }),
41
- }, (table) => ({
42
- idx_entity_locale_sort: index('idx_${r}_entity_locale_sort').on(table.entityId, table.locale, table.sortOrder),
43
- }))`}function pe(t){let e=t.name,r=`${e}_layout_translations`,o=`${e}_layout`;return `export const ${r} = pgTable('${r}', {
44
- id: uuid('id').primaryKey().defaultRandom(),
45
- layoutId: uuid('layout_id').notNull().references(() => ${o}.id, { onDelete: 'cascade' }),
46
- locale: varchar('locale', { length: 10 }).notNull(),
47
- fields: jsonb('fields').notNull(),
48
- }, (table) => ({
49
- uniqueLayoutLocale: unique().on(table.layoutId, table.locale),
50
- }))`}function fe(t){let e=t.name,r=`${e}_versions`;return `export const ${r} = pgTable('${r}', {
51
- id: uuid('id').primaryKey().defaultRandom(),
52
- entityId: uuid('entity_id').notNull().references(() => ${e}.id, { onDelete: 'cascade' }),
53
- version: integer('version').notNull(),
54
- locale: varchar('locale', { length: 10 }).notNull().default('_'),
55
- data: jsonb('data').notNull(),
56
- delta: jsonb('delta'),
57
- status: varchar('status', { length: 20 }),
58
- createdBy: varchar('created_by', { length: 255 }),
59
- createdByName: varchar('created_by_name', { length: 255 }),
60
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
61
- isAutosave: boolean('is_autosave').notNull().default(false),
62
- }, (table) => ({
63
- uniqueEntityVersionLocale: unique().on(table.entityId, table.version, table.locale),
64
- }))`}function me(t){let e=Object.values(t.allFields).filter(n=>n.type==="blocks"&&!("localized"in n&&n.localized));if(e.length===0||!e.some(n=>n.type!=="blocks"?false:n.blocks.some(i=>Object.values(i.fields).some(a=>a.translatable))))return null;let o=`${t.name}_layout_translations`;return pgTable(o,{id:uuid("id").primaryKey().defaultRandom(),layoutId:uuid("layout_id").notNull(),locale:varchar("locale",{length:10}).notNull(),fields:jsonb("fields").notNull()},n=>({uniqueLayoutLocale:unique().on(n.layoutId,n.locale)}))}export{_ as CountCache,C as ReferencedEntityError,y as auditable,D as behavior,W as buildCursorCondition,V as decodeCursor,Q as defineEntity,J as encodeCursor,P as estimateRowCount,s as field,ue as generateContentLocksCode,se as generateDraftsCode,ce as generateLayoutCode,re as generateLayoutSchema,pe as generateLayoutTranslationCode,me as generateLayoutTranslationSchema,ae as generateLocaleStatusCode,Y as generateSchema,ee as generateSchemaCode,te as generateTranslationSchema,ne as generateTranslationSchemaCode,fe as generateVersionsCode,I as hasBlocksFields,de as hasTranslatableBlocks,h as hierarchical,le as isPublishable,oe as isVersionable,ie as needsLocaleStatus,g as publishable,x as revisionable,F as sluggable,f as slugify,v as timestamped};
@@ -1,417 +0,0 @@
1
- import { SQL } from 'drizzle-orm';
2
- import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
3
-
4
- /**
5
- * In-memory TTL cache for COUNT(*) query results.
6
- *
7
- * Reduces database load on paginated list pages where the total count
8
- * is recalculated on every pagination/sort/search interaction. The cache
9
- * is per-process (not shared across workers) with a short TTL (default 5s)
10
- * so counts are at most a few seconds stale.
11
- *
12
- * Cache keys include the entity name + serialized WHERE clause, so filtered
13
- * and unfiltered counts are cached independently.
14
- */
15
- /**
16
- * Interface for count cache implementations.
17
- * Used in client configs to avoid TypeScript private-field structural incompatibility
18
- * across separate .d.ts files.
19
- */
20
- interface CountCacheLike {
21
- get(key: string): number | undefined;
22
- set(key: string, count: number): void;
23
- invalidate(prefix: string): void;
24
- }
25
-
26
- /**
27
- * Cursor-based (keyset) pagination utilities.
28
- *
29
- * Cursor pagination avoids the performance cliff of OFFSET at scale (1M+ rows).
30
- * Instead of `OFFSET N`, it uses a WHERE condition:
31
- * `WHERE (sortField < lastValue) OR (sortField = lastValue AND id < lastId)`
32
- * which Postgres can serve from an index in constant time.
33
- *
34
- * The cursor is opaque to the client — base64-encoded JSON.
35
- *
36
- * Security:
37
- * - `field` must be whitelisted against the entity's actual fields
38
- * - `id` must be a valid UUID
39
- * - `value` is parameterized (never interpolated into SQL)
40
- * - Malformed cursors return null (caller returns 400)
41
- */
42
-
43
- /** Cursor input for keyset pagination. */
44
- interface CursorInput {
45
- /** Sort field name (e.g. 'createdAt'). Must be a real column on the entity. */
46
- field: string;
47
- /** Last seen value of the sort field. */
48
- value: string | number;
49
- /** Sort direction — must match the ORDER BY direction. */
50
- direction: 'asc' | 'desc';
51
- /** Tie-breaker: last seen entity ID. Required for non-unique sort fields. */
52
- id?: string;
53
- }
54
-
55
- /**
56
- * Optional admin UI configuration for entities.
57
- * Controls how entities appear in the admin sidebar, list pages, and forms.
58
- */
59
- interface EntityAdminConfig {
60
- /** Sidebar section: 'content' | 'structure' | custom string. Default: 'content' */
61
- group?: string;
62
- /** Plural display name for sidebar + list pages. Default: title-cased pluralized entity name */
63
- label?: string;
64
- /** Singular label for "New X" button. Default: title-cased entity name */
65
- labelSingular?: string;
66
- /** Lucide icon name as string, e.g. 'file-text' */
67
- icon?: string;
68
- /** Description shown on list page */
69
- description?: string;
70
- /** Form layout. Default: 'single' */
71
- layout?: 'single' | 'two-column';
72
- /** Fields to hide in the form */
73
- hiddenFields?: string[];
74
- /** Columns to hide in the list */
75
- hiddenColumns?: string[];
76
- /** Per-field label/description/placeholder overrides */
77
- fieldOverrides?: Record<string, {
78
- label?: string;
79
- description?: string;
80
- placeholder?: string;
81
- }>;
82
- /** Default list sort field. Default: 'createdAt' */
83
- defaultSort?: string;
84
- /** Default list sort direction. Default: 'desc' */
85
- defaultSortDirection?: 'asc' | 'desc';
86
- /** List page size. Default: 20 */
87
- pageSize?: number;
88
- /** For block editor: fields to show in Puck root config */
89
- rootFields?: string[];
90
- /** Suppress "New" button in the list page */
91
- disableCreate?: boolean;
92
- /** Order within sidebar group. Default: 0 */
93
- sortOrder?: number;
94
- }
95
-
96
- /**
97
- * Field type definitions
98
- * These define the structure and configuration for all field types
99
- */
100
- interface BaseFieldConfig {
101
- required?: boolean;
102
- default?: unknown;
103
- translatable?: boolean;
104
- indexed?: boolean;
105
- unique?: boolean;
106
- access?: {
107
- view?: string;
108
- edit?: string;
109
- };
110
- }
111
- interface IdField extends BaseFieldConfig {
112
- type: 'id';
113
- }
114
- interface TextField extends BaseFieldConfig {
115
- type: 'text';
116
- maxLength?: number;
117
- minLength?: number;
118
- pattern?: RegExp;
119
- }
120
- interface NumberField extends BaseFieldConfig {
121
- type: 'number';
122
- min?: number;
123
- max?: number;
124
- integer?: boolean;
125
- }
126
- interface BooleanField extends BaseFieldConfig {
127
- type: 'boolean';
128
- }
129
- interface DateField extends BaseFieldConfig {
130
- type: 'date';
131
- minDate?: Date;
132
- maxDate?: Date;
133
- }
134
- interface SelectField extends BaseFieldConfig {
135
- type: 'select';
136
- options: readonly string[];
137
- }
138
- interface ReferenceField extends BaseFieldConfig {
139
- type: 'reference';
140
- entity: string;
141
- cardinality: 'one' | 'many';
142
- onDelete?: 'cascade' | 'set-null' | 'restrict';
143
- }
144
- interface MediaField extends BaseFieldConfig {
145
- type: 'media';
146
- accept?: string[];
147
- maxSize?: number;
148
- }
149
- interface RichTextField extends BaseFieldConfig {
150
- type: 'richtext';
151
- blocks?: string[];
152
- }
153
- interface SlugField extends BaseFieldConfig {
154
- type: 'slug';
155
- from: string;
156
- unique?: boolean;
157
- }
158
- /**
159
- * Minimal block definition reference — used by BlocksField to type-check allowed blocks.
160
- * The full BlockDefinition (with label, etc.) lives in @murumets-ee/content.
161
- */
162
- interface BlockDefinitionRef {
163
- slug: string;
164
- fields: Record<string, FieldConfig>;
165
- }
166
- /**
167
- * Blocks field — ordered array of typed content blocks.
168
- * Each block instance stores its data in {entity}_layout table.
169
- *
170
- * @property blocks - Allowed block definitions
171
- * @property min/max - Block count constraints
172
- * @property localized - true = per-locale layouts, false = shared layout with translated content (default)
173
- */
174
- interface JsonField extends BaseFieldConfig {
175
- type: 'json';
176
- }
177
- interface BlocksField extends BaseFieldConfig {
178
- type: 'blocks';
179
- blocks: readonly BlockDefinitionRef[];
180
- min?: number;
181
- max?: number;
182
- localized?: boolean;
183
- }
184
- type FieldConfig = IdField | TextField | NumberField | BooleanField | DateField | SelectField | ReferenceField | MediaField | RichTextField | SlugField | JsonField | BlocksField;
185
-
186
- /**
187
- * Behavior system
188
- * Behaviors add common functionality to entities (fields + hooks)
189
- *
190
- * @typeParam F - The field map this behavior contributes. Defaults to
191
- * Record<string, FieldConfig> for backward compatibility.
192
- */
193
-
194
- interface Behavior<F extends Record<string, FieldConfig> = Record<string, FieldConfig>> {
195
- name: string;
196
- fields?: F;
197
- hooks?: {
198
- beforeCreate?: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
199
- afterCreate?: (entity: Record<string, unknown>) => Promise<void>;
200
- beforeUpdate?: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown>>;
201
- afterUpdate?: (entity: Record<string, unknown>) => Promise<void>;
202
- beforeDelete?: (id: string) => Promise<void>;
203
- afterDelete?: (id: string) => Promise<void>;
204
- };
205
- }
206
-
207
- /**
208
- * Entity definition API
209
- * The core function for defining entities with full type inference.
210
- *
211
- * The generic parameters are inferred from the call site:
212
- * - F is inferred from `definition.fields`
213
- * - B is inferred from `definition.behaviors` (as a tuple)
214
- *
215
- * The returned Entity carries the complete field map:
216
- * { id: IdField } & BehaviorFields & UserFields
217
- */
218
-
219
- /**
220
- * A fully resolved entity with merged behavior fields.
221
- * @typeParam AllFields - The complete field map (id + behaviors + user fields).
222
- */
223
- interface Entity<AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>> {
224
- name: string;
225
- kind?: 'collection' | 'singleton';
226
- fields: Record<string, FieldConfig>;
227
- behaviors?: Behavior[];
228
- scope?: 'global' | 'team' | 'user';
229
- access?: {
230
- view?: string;
231
- create?: string;
232
- update?: string;
233
- delete?: string;
234
- };
235
- /** Admin UI configuration — controls sidebar, list, and form display */
236
- admin?: EntityAdminConfig;
237
- allFields: AllFields;
238
- hooks: Behavior['hooks'];
239
- }
240
-
241
- /**
242
- * Type inference utilities for the entity system.
243
- * Maps field configurations to TypeScript types at compile time.
244
- *
245
- * Design principles:
246
- * - Keep type nesting shallow (no recursive types beyond bounded tuple walking)
247
- * - Use intermediate aliases to aid TS performance
248
- * - All types are pure — zero runtime footprint
249
- *
250
- * Usage:
251
- * const Article = defineEntity({ ... })
252
- * type ArticleDTO = InferEntity<typeof Article>
253
- */
254
-
255
- /**
256
- * Maps a single FieldConfig to its TypeScript output type.
257
- * Each branch is a shallow comparison — no recursion.
258
- */
259
- type FieldToTS<F extends FieldConfig> = F extends IdField ? string : F extends TextField ? string : F extends NumberField ? number : F extends BooleanField ? boolean : F extends DateField ? Date | string : F extends SelectField ? F['options'][number] : F extends ReferenceField ? F['cardinality'] extends 'many' ? string[] : string : F extends MediaField ? string : F extends RichTextField ? Record<string, unknown>[] : F extends SlugField ? string : F extends JsonField ? Record<string, unknown> : F extends BlocksField ? Array<{
260
- _block: string;
261
- _id: string;
262
- [key: string]: unknown;
263
- }> : never;
264
- /**
265
- * Extract keys of fields where `required` is literally `true`.
266
- * Fields without `required` or with `required?: false` are optional.
267
- */
268
- type RequiredFieldKeys<Fields extends Record<string, FieldConfig>> = {
269
- [K in keyof Fields]: Fields[K]['required'] extends true ? K : never;
270
- }[keyof Fields];
271
- type OptionalFieldKeys<Fields extends Record<string, FieldConfig>> = {
272
- [K in keyof Fields]: Fields[K]['required'] extends true ? never : K;
273
- }[keyof Fields];
274
- /**
275
- * Maps a full field record to its TypeScript output type.
276
- * Required fields are non-nullable; optional fields are `T | null | undefined`.
277
- *
278
- * The `id` field is always `string` and always present.
279
- * The `id` key from Fields is excluded to avoid duplication since
280
- * we hardcode `{ id: string }` at the front.
281
- */
282
- type InferEntityDTO<Fields extends Record<string, FieldConfig>> = {
283
- id: string;
284
- } & {
285
- [K in Exclude<RequiredFieldKeys<Fields>, 'id'>]: FieldToTS<Fields[K]>;
286
- } & {
287
- [K in OptionalFieldKeys<Fields>]?: FieldToTS<Fields[K]> | null;
288
- };
289
-
290
- /**
291
- * Minimal logger interface compatible with Pino.
292
- *
293
- * Defined locally to avoid a circular build dependency:
294
- * entity → logging → core → entity.
295
- *
296
- * Any Pino logger instance satisfies this interface via structural typing.
297
- */
298
- interface Logger {
299
- info(obj: Record<string, unknown>, msg: string): void;
300
- debug?(obj: Record<string, unknown>, msg: string): void;
301
- }
302
-
303
- /**
304
- * QueryClient - Read-only client for frontend use
305
- * SAFE for client bundles - NO 'server-only' import
306
- */
307
-
308
- interface QueryClientConfig<AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>> {
309
- entity: Entity<AllFields>;
310
- db: PostgresJsDatabase;
311
- logger?: Logger;
312
- /** Optional count cache for COUNT(*) query optimization. */
313
- countCache?: CountCacheLike;
314
- }
315
- interface FindByIdOptions {
316
- select?: string[];
317
- locale?: string;
318
- /** Default content locale. For localized blocks, NULL rows (from initial create)
319
- * are only returned as fallback when locale matches defaultLocale. */
320
- defaultLocale?: string;
321
- }
322
- interface FindManyOptions {
323
- where?: SQL | undefined;
324
- limit?: number;
325
- offset?: number;
326
- orderBy?: SQL | SQL[];
327
- select?: string[];
328
- locale?: string;
329
- defaultLocale?: string;
330
- /**
331
- * Cursor-based (keyset) pagination. When provided, replaces OFFSET with a
332
- * WHERE condition for O(1) page access at any depth. The `offset` option
333
- * is ignored when `cursor` is set.
334
- *
335
- * The cursor `field` must be a real column on the entity table.
336
- */
337
- cursor?: CursorInput;
338
- }
339
- interface CountOptions {
340
- where?: SQL | undefined;
341
- }
342
- /**
343
- * QueryClient - Read-only entity access for frontends
344
- *
345
- * Security:
346
- * - NO 'server-only' import (safe for client bundles)
347
- * - Uses read-only DB connection (PostgreSQL enforces with default_transaction_read_only=on)
348
- * - NO mutation methods on TypeScript type (create/update/delete don't exist)
349
- * - Auto-filters to published entities if entity has publishable() behavior
350
- *
351
- * Phase 1: Core read operations + publishable filtering
352
- * Phase 2 (TODO): Translation merging, reference population
353
- *
354
- * @typeParam AllFields - The entity's complete field map. Inferred automatically
355
- * when constructing via `createQueryClient(entity)`.
356
- */
357
- declare class QueryClient<AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>> {
358
- private entity;
359
- private db;
360
- private logger?;
361
- private table;
362
- private isPublishable;
363
- private countCache?;
364
- constructor(config: QueryClientConfig<AllFields>);
365
- /**
366
- * Find entity by ID
367
- * Auto-filters to published if entity has publishable() behavior
368
- * PHASE 3: Merges translations if locale is provided
369
- */
370
- findById(id: string, options?: FindByIdOptions): Promise<InferEntityDTO<AllFields> | null>;
371
- /**
372
- * Find multiple entities
373
- * Auto-filters to published if entity has publishable() behavior
374
- * PHASE 3: Merges translations if locale is provided
375
- */
376
- findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]>;
377
- /**
378
- * Count entities
379
- * Auto-filters to published if entity has publishable() behavior
380
- */
381
- count(options?: CountOptions): Promise<number>;
382
- /**
383
- * Build publish filter SQL condition.
384
- * When a locale is provided and a locale_status table exists,
385
- * uses COALESCE to check locale-specific status first, falling back to base status.
386
- */
387
- private buildPublishFilter;
388
- /**
389
- * Merge translations into entities for the specified locale.
390
- * Reads translatable field values from real columns on the translation row.
391
- */
392
- private mergeTranslations;
393
- /**
394
- * Get all blocks field names for this entity.
395
- */
396
- private getBlocksFields;
397
- /**
398
- * Load blocks for one or more entities from the layout table.
399
- *
400
- * Handles both block translation modes:
401
- * - Shared layout (localized: false): loads locale=NULL rows, merges translations
402
- * (keeps base data as fallback for untranslated fields — visitor-facing)
403
- * - Per-locale layout (localized: true): loads rows matching the provided locale only
404
- */
405
- private loadBlocks;
406
- /**
407
- * Attach loaded blocks to shaped DTOs.
408
- */
409
- private attachBlocks;
410
- /**
411
- * Build a cache key for a count query.
412
- * Includes the publishable filter implicitly (all QueryClient counts include it).
413
- */
414
- private buildCountCacheKey;
415
- }
416
-
417
- export { type CountOptions, type FindByIdOptions, type FindManyOptions, QueryClient, type QueryClientConfig };
@@ -1,6 +0,0 @@
1
- import{schemaRegistry as w}from"@murumets-ee/db";import{and as m,eq as b,inArray as C,isNull as D,or as x,sql as v}from"drizzle-orm";import{and as B,eq as L,gt as $,lt as P,or as S}from"drizzle-orm";function T(h,e){let t=h[e.field];if(!t)return null;let r=e.direction==="desc"?P:$,n=r(t,e.value);if(!e.id)return n;let s=h.id;if(!s)return n;let u=r(s,e.id);return S(n,B(L(t,e.value),u))}function I(h,e,t){if(!e)return null;let i={},r=t?.select||Object.keys(h.allFields);for(let n of r){if(!t?.includeInternal&&n.startsWith("_"))continue;let s=h.allFields[n];s&&s.type!=="blocks"&&(i[n]=e[n])}return i}function E(h,e,t){return e.map(i=>I(h,i,t))}var O=class{entity;db;logger;table;isPublishable;countCache;constructor(e){this.entity=e.entity,this.db=e.db,this.logger=e.logger,this.countCache=e.countCache,this.isPublishable=e.entity.behaviors?.some(i=>i.name==="publishable")??!1;let t=w.get(e.entity.name);if(!t)throw new Error(`Schema for entity '${e.entity.name}' not found in registry. Ensure schemas are generated and registered before creating QueryClient.`);this.table=t}async findById(e,t){this.logger?.info({entity:this.entity.name,id:e,locale:t?.locale},"Query: Finding entity by ID");let i=[b(this.table.id,e)];this.isPublishable&&i.push(this.buildPublishFilter(t?.locale));let[r]=await this.db.select().from(this.table).where(m(...i));if(!r)return null;let n=I(this.entity,r,{select:t?.select});if(!n)return null;if(this.getBlocksFields().length>0){let s=await this.loadBlocks([n.id],t?.locale,{defaultLocale:t?.defaultLocale});this.attachBlocks([n],s)}return t?.locale?(await this.mergeTranslations([n],t.locale))[0]:n}async findMany(e){this.logger?.info({entity:this.entity.name,options:e,locale:e?.locale},"Query: Finding entities");let t=this.db.select().from(this.table).$dynamic(),i=[];if(this.isPublishable&&i.push(this.buildPublishFilter(e?.locale)),e?.where&&i.push(e.where),e?.cursor){let s=this.entity.allFields;if(!(e.cursor.field in s)&&e.cursor.field!=="id")throw new Error(`Invalid cursor field: '${e.cursor.field}' is not a field on '${this.entity.name}'`);let u=T(this.table,e.cursor);u&&i.push(u)}if(i.length>0&&(t=t.where(m(...i))),e?.limit&&(t=t.limit(e.limit)),e?.offset&&!e?.cursor&&(t=t.offset(e.offset)),e?.orderBy){let s=Array.isArray(e.orderBy)?e.orderBy:[e.orderBy];t=t.orderBy(...s)}let r=await t,n=E(this.entity,r,{select:e?.select}).filter(s=>s!==null);if(this.getBlocksFields().length>0&&n.length>0){let s=n.map(g=>g.id),u=await this.loadBlocks(s,e?.locale,{defaultLocale:e?.defaultLocale});this.attachBlocks(n,u)}return e?.locale?await this.mergeTranslations(n,e.locale):n}async count(e){this.logger?.info({entity:this.entity.name,options:e},"Query: Counting entities");let t=this.buildCountCacheKey(e?.where);if(this.countCache){let s=this.countCache.get(t);if(s!==void 0)return this.logger?.debug?.({entity:this.entity.name,cached:s},"Count cache hit"),s}let i=this.db.select({count:v`count(*)`}).from(this.table).$dynamic();if(this.isPublishable){let s=this.buildPublishFilter();e?.where?i=i.where(m(s,e.where)):i=i.where(s)}else e?.where&&(i=i.where(e.where));let[r]=await i,n=Number(r.count);return this.countCache&&this.countCache.set(t,n),n}buildPublishFilter(e){if(e){let t=w.get(`${this.entity.name}_locale_status`);if(t)return v`COALESCE(
2
- (SELECT ${t.status} FROM ${t}
3
- WHERE ${t.entityId} = ${this.table.id}
4
- AND ${t.locale} = ${e}),
5
- ${this.table.status}
6
- ) = 'published'`}return b(this.table.status,"published")}async mergeTranslations(e,t){if(!e.length)return e;let i=`${this.entity.name}_translations`,r=w.get(i);if(!r)return e;let n=e.map(l=>l.id),s=await this.db.select().from(r).where(m(C(r.entityId,n),b(r.locale,t))),u=Object.entries(this.entity.allFields).filter(([l,f])=>f.translatable).map(([l])=>l),g=new Map;for(let l of s){let f={};for(let o of u){let a=l[o];a!=null&&(f[o]=a)}g.set(l.entityId,f)}return e.map(l=>{let f=g.get(l.id);return f?{...l,...f}:l})}getBlocksFields(){return Object.entries(this.entity.allFields).filter(([e,t])=>t.type==="blocks").map(([e,t])=>({name:e,config:t}))}async loadBlocks(e,t,i){let r=this.getBlocksFields();if(r.length===0||e.length===0)return new Map;let n=w.get(`${this.entity.name}_layout`);if(!n)return new Map;let s=r.filter(({config:l})=>!("localized"in l&&l.localized)),u=r.filter(({config:l})=>"localized"in l&&l.localized),g=new Map;if(s.length>0){let l=await this.db.select().from(n).where(m(C(n.entityId,e),D(n.locale))).orderBy(n.sortOrder),f;if(t&&l.length>0){let o=w.get(`${this.entity.name}_layout_translations`);if(o){let a=l.map(d=>d.id),c=await this.db.select().from(o).where(m(C(o.layoutId,a),b(o.locale,t)));f=new Map;for(let d of c)f.set(d.layoutId,d.fields??{})}}for(let o of l){let a=o.entityId,c=o.fieldName;g.has(a)||g.set(a,{});let d=g.get(a);d[c]||(d[c]=[]);let p=o.data??{},y=f?.get(o.id);d[c].push({_block:o.blockType,_id:o.id,...p,...y??{}})}}if(u.length>0){let l=!t||t===i?.defaultLocale,f=t?l?x(b(n.locale,t),D(n.locale)):b(n.locale,t):D(n.locale),o=await this.db.select().from(n).where(m(C(n.entityId,e),f)).orderBy(n.sortOrder),a=new Map;for(let c of o){let d=`${c.entityId}::${c.fieldName}`;a.has(d)||a.set(d,{localeRows:[],nullRows:[]});let p=a.get(d);c.locale?p.localeRows.push(c):p.nullRows.push(c)}for(let[,{localeRows:c,nullRows:d}]of a){let p=c.length>0?c:d;for(let y of p){let F=y.entityId,k=y.fieldName;g.has(F)||g.set(F,{});let R=g.get(F);R[k]||(R[k]=[]);let A=y.data??{};R[k].push({_block:y.blockType,_id:y.id,...A})}}}return g}attachBlocks(e,t){let i=this.getBlocksFields();if(i.length!==0)for(let r of e){let n=r.id,s=t.get(n)??{};for(let{name:u}of i)r[u]=s[u]??[]}}buildCountCacheKey(e){let t=`query:${this.entity.name}`;return e?`${t}:${String(e)}`:t}};export{O as QueryClient};