@murumets-ee/entity 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +215 -119
- package/dist/admin/index.d.mts.map +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/index.mjs.map +1 -1
- package/dist/index.d.mts +138 -663
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/query/index.d.mts +48 -0
- package/dist/query/index.d.mts.map +1 -1
- package/dist/query/index.mjs +2 -2
- package/dist/query/index.mjs.map +1 -1
- package/dist/refs/index.d.mts +17 -0
- package/dist/refs/index.d.mts.map +1 -1
- package/package.json +5 -2
package/dist/query/index.d.mts
CHANGED
|
@@ -20,6 +20,17 @@ import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
|
20
20
|
interface CountCacheLike {
|
|
21
21
|
get(key: string): number | undefined;
|
|
22
22
|
set(key: string, count: number): void;
|
|
23
|
+
/**
|
|
24
|
+
* Cache-or-compute with single-flight semantics.
|
|
25
|
+
*
|
|
26
|
+
* If the entry is cached and unexpired, returns it immediately (no promise
|
|
27
|
+
* allocation in the hot path). Otherwise, if a refresh is already in flight
|
|
28
|
+
* for the same key, returns that in-flight promise instead of starting a
|
|
29
|
+
* second one — preventing the thundering-herd burst of identical
|
|
30
|
+
* `count(*)` queries when N concurrent requests cross the TTL boundary
|
|
31
|
+
* together.
|
|
32
|
+
*/
|
|
33
|
+
getOrCompute(key: string, compute: () => Promise<number>): Promise<number> | number;
|
|
23
34
|
invalidate(prefix: string): void;
|
|
24
35
|
}
|
|
25
36
|
//#endregion
|
|
@@ -106,6 +117,23 @@ interface BaseFieldConfig {
|
|
|
106
117
|
translatable?: boolean;
|
|
107
118
|
indexed?: boolean;
|
|
108
119
|
unique?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Marks the field as **system-managed**: stored as a real column, populated
|
|
122
|
+
* by behavior hooks or trusted server-side transitions, but NOT writable
|
|
123
|
+
* through the public `AdminClient.create/update` surface.
|
|
124
|
+
*
|
|
125
|
+
* - Caller-supplied values for internal fields are silently stripped before
|
|
126
|
+
* hooks run (so an HTTP PATCH cannot poison a workflow state).
|
|
127
|
+
* - `beforeCreate` / `beforeUpdate` hooks may still set them (they run after
|
|
128
|
+
* the strip), and the values are preserved through validation.
|
|
129
|
+
* - Trusted server code that needs to write internals directly — e.g.
|
|
130
|
+
* workflow transitions invoked from an authorized admin route — must use
|
|
131
|
+
* `AdminClient.updateInternal()`, which bypasses the strip.
|
|
132
|
+
*
|
|
133
|
+
* Use this flag on any field added by a behavior whose value represents a
|
|
134
|
+
* controlled state machine (e.g. `_workflowStatus`), not user input.
|
|
135
|
+
*/
|
|
136
|
+
internal?: boolean;
|
|
109
137
|
access?: {
|
|
110
138
|
view?: string;
|
|
111
139
|
edit?: string;
|
|
@@ -192,6 +220,24 @@ type FieldConfig = IdField | TextField | NumberField | BooleanField | DateField
|
|
|
192
220
|
* from its `contextResolver` and forwarded into every hook so behaviors never
|
|
193
221
|
* have to reach into AsyncLocalStorage themselves — that pattern breaks under
|
|
194
222
|
* bundlers (e.g. Turbopack) that duplicate module instances across boundaries.
|
|
223
|
+
*
|
|
224
|
+
* `loadCurrent` is a lazy, memoized loader for the *current* entity row,
|
|
225
|
+
* provided by AdminClient on update/delete codepaths. Hooks that need to
|
|
226
|
+
* validate against the existing state (e.g. workflowable's transition
|
|
227
|
+
* checker) call it; behaviors that don't, don't pay for the round trip.
|
|
228
|
+
* Returns `null` when no entity matches the id (rare — usually means the
|
|
229
|
+
* row was deleted concurrently). On create, `loadCurrent` is undefined.
|
|
230
|
+
*
|
|
231
|
+
* `viaInternal` is `true` when the hook is running on an `AdminClient.updateInternal`
|
|
232
|
+
* call (the trusted server-side path; public PATCH always sets it false).
|
|
233
|
+
* Hooks SHOULD treat this as informational only — the route layer has
|
|
234
|
+
* authorized the *capability*, but the hook is still responsible for
|
|
235
|
+
* enforcing structural invariants. Workflowable, for example, validates
|
|
236
|
+
* the `_workflowStatus` transition table on every update regardless of
|
|
237
|
+
* `viaInternal` so that even a route-layer bug cannot push the workflow
|
|
238
|
+
* row into an illegal state. Use `viaInternal` when a hook genuinely
|
|
239
|
+
* needs to differentiate (e.g. side effects that should only fire when
|
|
240
|
+
* a real user — not the seed loader — initiates the change).
|
|
195
241
|
*/
|
|
196
242
|
interface BehaviorContext {
|
|
197
243
|
user?: {
|
|
@@ -199,6 +245,8 @@ interface BehaviorContext {
|
|
|
199
245
|
name?: string;
|
|
200
246
|
email?: string;
|
|
201
247
|
};
|
|
248
|
+
loadCurrent?: () => Promise<Record<string, unknown> | null>;
|
|
249
|
+
viaInternal?: boolean;
|
|
202
250
|
}
|
|
203
251
|
interface Behavior<F extends Record<string, FieldConfig> = {}> {
|
|
204
252
|
name: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/count-cache.ts","../../src/cursor.ts","../../src/admin-config.ts","../../src/fields/base.ts","../../src/behaviors/types.ts","../../src/define-entity.ts","../../src/shared/entity-data-ops.ts","../../src/types/infer.ts","../../src/types/logger.ts","../../src/query/client.ts"],"mappings":";;;;;;;;AAiBA;;;;;;;;;;;UAAiB,cAAA;EACf,GAAA,CAAI,GAAA;EACJ,GAAA,CAAI,GAAA,UAAa,KAAA;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/count-cache.ts","../../src/cursor.ts","../../src/admin-config.ts","../../src/fields/base.ts","../../src/behaviors/types.ts","../../src/define-entity.ts","../../src/shared/entity-data-ops.ts","../../src/types/infer.ts","../../src/types/logger.ts","../../src/query/client.ts"],"mappings":"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"}
|
package/dist/query/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import{schemaRegistry as e}from"@murumets-ee/db";import{and as t,eq as n,getTableColumns as r,gt as i,inArray as a,isNull as o,lt as s,or as c,sql as l}from"drizzle-orm";function u(e,a){let o=r(e),l=o[a.field];if(!l)return null;let u=a.direction===`desc`?s:i,d=u(l,a.value);if(!a.id)return d;let f=o.id;if(!f)return d;let p=u(f,a.id);return c(d,t(n(l,a.value),p))}function d(e,t,n){if(!t)return null;let r={},i=n?.select||Object.keys(e.allFields);for(let a of i){if(!n?.includeInternal
|
|
1
|
+
import{schemaRegistry as e}from"@murumets-ee/db";import{and as t,eq as n,getTableColumns as r,gt as i,inArray as a,isNull as o,lt as s,or as c,sql as l}from"drizzle-orm";function u(e,a){let o=r(e),l=o[a.field];if(!l)return null;let u=a.direction===`desc`?s:i,d=u(l,a.value);if(!a.id)return d;let f=o.id;if(!f)return d;let p=u(f,a.id);return c(d,t(n(l,a.value),p))??d}function d(e,t,n){if(!t)return null;let r={},i=n?.select||Object.keys(e.allFields);for(let a of i){let i=e.allFields[a];if(!n?.includeInternal){let e=a.startsWith(`_`),t=i?.internal===!0;if(e||t)continue}i&&i.type!==`blocks`&&(r[a]=t[a])}return r}function f(e,t,n){return t.map(t=>d(e,t,n))}function p(e){return e.entity.allFields}function m(e){return Object.entries(p(e)).filter(([e,t])=>t.type===`blocks`).map(([e,t])=>({name:e,config:t}))}async function h(r,i,o){if(!i.length)return i;let s=`${r.entity.name}_translations`,c=e.get(s);if(!c)return i;let l=i.map(e=>e.id),u=await r.db.select().from(c).where(t(a(c.entityId,l),n(c.locale,o))),d=Object.entries(p(r)).filter(([e,t])=>t.translatable).map(([e])=>e),f=new Map;for(let e of u){let t={};for(let n of d){let r=e[n];r!=null&&(t[n]=r)}f.set(e.entityId,t)}return i.map(e=>{let t=f.get(e.id);return t?{...e,...t}:e})}async function g(r,i,s,l){let u=m(r);if(u.length===0||i.length===0)return new Map;let d=e.get(`${r.entity.name}_layout`);if(!d)return new Map;let f=u.filter(({config:e})=>!(`localized`in e&&e.localized)),p=u.filter(({config:e})=>`localized`in e&&e.localized),h=new Map;if(f.length>0){let c=await r.db.select().from(d).where(t(a(d.entityId,i),o(d.locale))).orderBy(d.sortOrder),u;if(s&&c.length>0){let i=e.get(`${r.entity.name}_layout_translations`);if(i){let e=c.map(e=>e.id),o=await r.db.select().from(i).where(t(a(i.layoutId,e),n(i.locale,s)));u=new Map;for(let e of o)u.set(e.layoutId,e.fields??{})}}let p;if(l?.strictTranslations){p=new Map;for(let{config:e}of f)if(e.type===`blocks`)for(let t of e.blocks)p.set(t.slug,t.fields)}for(let e of c){let t=e.entityId,n=e.fieldName,r=h.get(t);r||(r={},h.set(t,r)),r[n]||(r[n]=[]);let i=e.data??{},a=u?.get(e.id),o={_block:e.blockType,_id:e.id,...i,...a??{}};if(s&&p){let t=e.blockType,n=p.get(t);if(n)for(let[e,t]of Object.entries(n))t.translatable&&!a?.[e]&&(o[e]=``)}r[n].push(o)}}if(p.length>0){let e=!s||s===l?.defaultLocale,u;u=s?e?c(n(d.locale,s),o(d.locale))??n(d.locale,s):n(d.locale,s):o(d.locale);let f=await r.db.select().from(d).where(t(a(d.entityId,i),u)).orderBy(d.sortOrder),p=new Map;for(let e of f){let t=`${e.entityId}::${e.fieldName}`,n=p.get(t);n||(n={localeRows:[],nullRows:[]},p.set(t,n)),e.locale?n.localeRows.push(e):n.nullRows.push(e)}for(let[,{localeRows:e,nullRows:t}]of p){let n=e.length>0?e:t;for(let e of n){let t=e.entityId,n=e.fieldName,r=h.get(t);r||(r={},h.set(t,r)),r[n]||(r[n]=[]);let i=e.data??{};r[n].push({_block:e.blockType,_id:e.id,...i})}}}return h}function _(e,t,n){let r=m(e);if(r.length!==0)for(let e of t){let t=e.id,i=n.get(t)??{};for(let{name:t}of r)e[t]=i[t]??[]}}function v(e,t,n,r){let i=n?`${n}:${e}`:e;return r&&(i=`${i}@${r}`),t?`${i}:${String(t)}`:i}var y=class extends Error{constructor(e){super(e),this.name=`ForbiddenError`}};async function b(e){if(!e)throw new y(`No context resolver configured on AdminClient/QueryClient. Use createAdminClient() from @murumets-ee/core/clients, or pass a contextResolver in config.`);let t=await e();if(!t)throw new y(`Context resolver returned no context. Ensure auth is configured in your request handler, or use runAsCli() for CLI scripts.`);return t}function x(e,t){return n(e.table._scopeId,t)}async function S(e,t,n,r){let i=await b(t),a=i.user.groups[0];if(!a)throw new y(`User '${i.user.id}' has no role assigned.`);if(!i.checker(a,e.name,n))throw new y(`Forbidden: role '${a}' cannot ${n} '${e.name}'`);let o;if(e.scope&&e.scope!==`global`&&(o=i.scope?.id,!o&&r?.strictScope))throw new y(`Entity '${e.name}' requires scope '${e.scope}' but no scope is set in context.`);let s={user:{id:i.user.id,name:i.user.name,email:i.user.email}};return{security:i,scopeId:o,behaviorCtx:s}}var C=class{entity;db;logger;table;isPublishable;countCache;contextResolver;get ctx(){return{entity:this.entity,db:this.db,table:this.table,resolveContext:this.contextResolver}}constructor(t){this.entity=t.entity,this.db=t.db,this.logger=t.logger,this.countCache=t.countCache,this.contextResolver=t.contextResolver,this.isPublishable=t.entity.behaviors?.some(e=>e.name===`publishable`)??!1;let n=e.get(t.entity.name);if(!n)throw Error(`Schema for entity '${t.entity.name}' not found in registry. Ensure schemas are generated and registered before creating QueryClient.`);this.table=n}async findById(e,r){this.logger?.info({entity:this.entity.name,id:e,locale:r?.locale},`Query: Finding entity by ID`);let{scopeId:i}=await S(this.entity,this.contextResolver,`view`),a=[n(this.table.id,e)];i&&a.push(x(this.ctx,i)),this.isPublishable&&a.push(this.buildPublishFilter(r?.locale));let[o]=await this.db.select().from(this.table).where(t(...a));if(!o)return null;let s=d(this.entity,o,{select:r?.select});if(!s)return null;if(m(this.ctx).length>0){let e=await g(this.ctx,[s.id],r?.locale,{defaultLocale:r?.defaultLocale});_(this.ctx,[s],e)}return r?.locale?(await h(this.ctx,[s],r.locale))[0]:s}async findMany(e){this.logger?.info({entity:this.entity.name,options:e,locale:e?.locale},`Query: Finding entities`);let{scopeId:n}=await S(this.entity,this.contextResolver,`view`),r=this.db.select().from(this.table).$dynamic(),i=[];if(n&&i.push(x(this.ctx,n)),this.isPublishable&&i.push(this.buildPublishFilter(e?.locale)),e?.where&&i.push(e.where),e?.cursor){let t=p(this.ctx);if(!(e.cursor.field in t)&&e.cursor.field!==`id`)throw Error(`Invalid cursor field: '${e.cursor.field}' is not a field on '${this.entity.name}'`);let n=u(this.table,e.cursor);n&&i.push(n)}if(i.length>0&&(r=r.where(t(...i))),e?.limit&&(r=r.limit(e.limit)),e?.offset&&!e?.cursor&&(r=r.offset(e.offset)),e?.orderBy){let t=Array.isArray(e.orderBy)?e.orderBy:[e.orderBy];r=r.orderBy(...t)}let a=await r,o=f(this.entity,a,{select:e?.select}).filter(e=>e!==null);if(m(this.ctx).length>0&&o.length>0){let t=o.map(e=>e.id),n=await g(this.ctx,t,e?.locale,{defaultLocale:e?.defaultLocale});_(this.ctx,o,n)}return e?.locale?await h(this.ctx,o,e.locale):o}async count(e){this.logger?.info({entity:this.entity.name,options:e},`Query: Counting entities`);let{scopeId:n}=await S(this.entity,this.contextResolver,`view`),r=v(this.entity.name,e?.where,`query`,n),i=async()=>{let r=this.db.select({count:l`count(*)`}).from(this.table).$dynamic(),i=[];n&&i.push(x(this.ctx,n)),this.isPublishable&&i.push(this.buildPublishFilter()),e?.where&&i.push(e.where),i.length>0&&(r=r.where(t(...i)));let[a]=await r;return Number(a.count)};return this.countCache?this.countCache.getOrCompute(r,i):i()}buildPublishFilter(t){if(t){let n=e.get(`${this.entity.name}_locale_status`);if(n)return l`COALESCE(
|
|
2
2
|
(SELECT ${n.status} FROM ${n}
|
|
3
3
|
WHERE ${n.entityId} = ${this.table.id}
|
|
4
4
|
AND ${n.locale} = ${t}),
|
|
5
5
|
${this.table.status}
|
|
6
|
-
) = 'published'`}return n(this.table.status,`published`)}};export{
|
|
6
|
+
) = 'published'`}return n(this.table.status,`published`)}};export{C as QueryClient};
|
|
7
7
|
//# sourceMappingURL=index.mjs.map
|
package/dist/query/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/cursor.ts","../../src/dto-shaper.ts","../../src/shared/entity-data-ops.ts","../../src/query/client.ts"],"sourcesContent":["/**\n * Cursor-based (keyset) pagination utilities.\n *\n * Cursor pagination avoids the performance cliff of OFFSET at scale (1M+ rows).\n * Instead of `OFFSET N`, it uses a WHERE condition:\n * `WHERE (sortField < lastValue) OR (sortField = lastValue AND id < lastId)`\n * which Postgres can serve from an index in constant time.\n *\n * The cursor is opaque to the client — base64-encoded JSON.\n *\n * Security:\n * - `field` must be whitelisted against the entity's actual fields\n * - `id` must be a valid UUID\n * - `value` is parameterized (never interpolated into SQL)\n * - Malformed cursors return null (caller returns 400)\n */\n\nimport { and, eq, getTableColumns, gt, lt, or, type SQL } from 'drizzle-orm'\nimport type { PgTable } from 'drizzle-orm/pg-core'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Cursor input for keyset pagination. */\nexport interface CursorInput {\n /** Sort field name (e.g. 'createdAt'). Must be a real column on the entity. */\n field: string\n /** Last seen value of the sort field. */\n value: string | number\n /** Sort direction — must match the ORDER BY direction. */\n direction: 'asc' | 'desc'\n /** Tie-breaker: last seen entity ID. Required for non-unique sort fields. */\n id?: string\n}\n\n/** Decoded cursor (internal, after validation). */\ninterface DecodedCursor {\n field: string\n value: string | number\n direction: 'asc' | 'desc'\n id?: string\n}\n\n// ---------------------------------------------------------------------------\n// UUID validation (same format used throughout the toolkit)\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n// ---------------------------------------------------------------------------\n// Encode / decode\n// ---------------------------------------------------------------------------\n\n/**\n * Encode a cursor for API transport (base64url).\n * Built from the last item in a result set.\n */\nexport function encodeCursor(\n item: Record<string, unknown>,\n sortField: string,\n direction: 'asc' | 'desc',\n): string {\n const payload: CursorInput = {\n field: sortField,\n value: item[sortField] as string | number,\n direction,\n id: item.id as string | undefined,\n }\n return btoa(JSON.stringify(payload))\n}\n\n/**\n * Decode and validate a cursor string from query params.\n * Returns null if the cursor is malformed, tampered, or invalid.\n *\n * Security: the `field` value is NOT validated here — the caller must\n * whitelist it against the entity's actual columns.\n */\nexport function decodeCursor(encoded: string): DecodedCursor | null {\n try {\n const json = atob(encoded)\n const parsed: unknown = JSON.parse(json)\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n\n // Validate field\n if (typeof obj.field !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(obj.field)) {\n return null\n }\n\n // Validate value (string or number)\n if (typeof obj.value !== 'string' && typeof obj.value !== 'number') {\n return null\n }\n\n // Validate direction\n if (obj.direction !== 'asc' && obj.direction !== 'desc') {\n return null\n }\n\n // Validate id (optional, must be UUID if present)\n if (obj.id !== undefined) {\n if (typeof obj.id !== 'string' || !UUID_RE.test(obj.id)) {\n return null\n }\n }\n\n return {\n field: obj.field,\n value: obj.value,\n direction: obj.direction,\n id: obj.id as string | undefined,\n }\n } catch {\n return null\n }\n}\n\n// ---------------------------------------------------------------------------\n// SQL condition builder\n// ---------------------------------------------------------------------------\n\n/**\n * Build the keyset WHERE condition from a decoded cursor.\n *\n * For DESC: `WHERE (field < value) OR (field = value AND id < cursorId)`\n * For ASC: `WHERE (field > value) OR (field = value AND id > cursorId)`\n *\n * The caller must verify that `cursor.field` exists on the table before calling.\n *\n * @param table - Drizzle table with columns\n * @param cursor - Decoded and validated cursor\n * @returns SQL condition, or null if the field doesn't exist on the table\n */\nexport function buildCursorCondition(\n table: PgTable,\n cursor: DecodedCursor,\n): 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 return or(fieldCondition, and(eq(column, cursor.value), idCondition))!\n}\n","/**\n * DTO Shaper\n * Transforms raw DB rows into clean DTOs.\n * All fields are real columns — just read from row directly.\n */\n\nimport type { Entity } from './define-entity.js'\nimport type { FieldConfig } from './fields/base.js'\nimport type { InferEntityDTO } from './types/infer.js'\n\nexport interface ShapeDtoOptions {\n select?: string[] // Explicit field selection\n includeInternal?: boolean // Include _version, _scopeId\n}\n\n/**\n * Shape a raw DB row into a typed DTO.\n * Every field is a real column — read directly from row.\n */\nexport function shapeDto<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n row: Record<string, unknown>,\n options?: ShapeDtoOptions,\n): InferEntityDTO<AllFields> | null {\n if (!row) return null\n\n const result: Record<string, unknown> = {}\n const fieldsToInclude = options?.select || Object.keys(entity.allFields)\n\n for (const fieldName of fieldsToInclude) {\n if (!options?.includeInternal && fieldName.startsWith('_')) continue\n\n const fieldConfig = (entity.allFields as Record<string, FieldConfig>)[fieldName]\n if (!fieldConfig) continue\n\n // Blocks are loaded separately from layout tables\n if (fieldConfig.type === 'blocks') continue\n\n result[fieldName] = row[fieldName]\n }\n\n return result as InferEntityDTO<AllFields>\n}\n\n/**\n * Shape multiple rows\n */\nexport function shapeDtos<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n rows: Record<string, unknown>[],\n options?: ShapeDtoOptions,\n): (InferEntityDTO<AllFields> | null)[] {\n return rows.map((row) => shapeDto(entity, row, options))\n}\n","/**\n * Shared entity data operations.\n *\n * Extracted from AdminClient and QueryClient to eliminate duplication.\n * These are standalone functions that take an EntityContext — both clients\n * satisfy this interface via `this`.\n */\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { and, eq, inArray, isNull, or, type SQL } from 'drizzle-orm'\nimport type { PgTableWithColumns } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { Entity } from '../define-entity.js'\nimport type { FieldConfig } from '../fields/base.js'\n\n// ---------------------------------------------------------------------------\n// Context interface — satisfied by both AdminClient and QueryClient via `this`\n// ---------------------------------------------------------------------------\n\n/**\n * Security context resolved per-request. Provided by the consumer\n * (e.g., via React.cache() in Next.js, or runAsCli for CLI).\n * The entity package has zero knowledge of how this is resolved.\n */\nexport interface SecurityContext {\n user: { id: string; groups: string[]; name?: string; email?: string }\n checker: (role: string, resource: string, action: string) => boolean\n scope?: { type: string; id: string }\n}\n\n/**\n * Function that resolves the current request's security context.\n * Injected at construction time — the entity package never imports\n * @murumets-ee/core or any framework-specific code.\n *\n * Returns undefined only when intentionally skipping enforcement\n * (should not happen in production — throw ForbiddenError instead).\n */\nexport type ContextResolver = () => SecurityContext | undefined | Promise<SecurityContext | undefined>\n\nexport interface EntityContext {\n entity: Entity\n db: PostgresJsDatabase\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns require PgTableWithColumns<any>\n table: PgTableWithColumns<any>\n /** Resolves the current request's security context. */\n resolveContext?: ContextResolver\n}\n\n// ---------------------------------------------------------------------------\n// Field accessors\n// ---------------------------------------------------------------------------\n\n/** Get the entity's full field map with proper typing. Eliminates repeated casts. */\nexport function getAllFields(ctx: EntityContext): Record<string, FieldConfig> {\n return ctx.entity.allFields as Record<string, FieldConfig>\n}\n\n/** Get all blocks field definitions for this entity. */\nexport function getBlocksFields(ctx: EntityContext): Array<{ name: string; config: FieldConfig }> {\n return Object.entries(getAllFields(ctx))\n .filter(([_, config]) => config.type === 'blocks')\n .map(([name, config]) => ({ name, config }))\n}\n\n// ---------------------------------------------------------------------------\n// Translation merging\n// ---------------------------------------------------------------------------\n\n/**\n * Merge translations into entities for the specified locale.\n * Reads translatable field values from real columns on the translation row.\n */\nexport async function mergeTranslations<T extends Record<string, unknown>>(\n ctx: EntityContext,\n entities: T[],\n locale: string,\n): Promise<T[]> {\n if (!entities.length) return entities\n\n const translationTableName = `${ctx.entity.name}_translations`\n const translationTable = schemaRegistry.get(translationTableName)\n\n if (!translationTable) return entities\n\n const entityIds = entities.map((e) => e.id)\n\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(\n and(inArray(translationTable.entityId, entityIds), eq(translationTable.locale, locale)),\n )\n\n // Get translatable field names\n const translatableFields = Object.entries(getAllFields(ctx))\n .filter(([_, config]) => config.translatable)\n .map(([name]) => name)\n\n const translationMap = new Map<unknown, Record<string, unknown>>()\n for (const translation of translations) {\n const translatedValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n const value = (translation as Record<string, unknown>)[fieldName]\n if (value !== undefined && value !== null) {\n translatedValues[fieldName] = value\n }\n }\n translationMap.set(translation.entityId, translatedValues)\n }\n\n return entities.map((entity) => {\n const translatedFields = translationMap.get(entity.id)\n if (!translatedFields) return entity\n return { ...entity, ...translatedFields }\n })\n}\n\n// ---------------------------------------------------------------------------\n// Block loading\n// ---------------------------------------------------------------------------\n\nexport interface LoadBlocksOptions {\n defaultLocale?: string\n /**\n * When true (admin editing), clears untranslated translatable block fields\n * to empty string — signals incomplete translation in the editing UI.\n * When false (visitor-facing), preserves base data as fallback.\n */\n strictTranslations?: boolean\n}\n\n/**\n * Load blocks for one or more entities from the layout table.\n *\n * Handles both block translation modes:\n * - Shared layout (localized: false): loads locale=NULL rows, merges translations\n * - Per-locale layout (localized: true): loads rows matching the provided locale only\n */\nexport async function loadBlocks(\n ctx: EntityContext,\n entityIds: string[],\n locale?: string,\n options?: LoadBlocksOptions,\n): Promise<Map<string, Record<string, unknown[]>>> {\n const blocksFields = getBlocksFields(ctx)\n if (blocksFields.length === 0 || entityIds.length === 0) {\n return new Map()\n }\n\n const layoutTable = schemaRegistry.get(`${ctx.entity.name}_layout`)\n if (!layoutTable) return new Map()\n\n // Determine block field modes\n const sharedFields = blocksFields.filter(\n ({ config }) => !('localized' in config && config.localized),\n )\n const localizedFields = blocksFields.filter(\n ({ config }) => 'localized' in config && config.localized,\n )\n\n const result = new Map<string, Record<string, unknown[]>>()\n\n // ---- Shared blocks: locale IS NULL, translations from layout_translations ----\n if (sharedFields.length > 0) {\n const rows = await ctx.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), isNull(layoutTable.locale)))\n .orderBy(layoutTable.sortOrder)\n\n // Load translations if locale provided\n let blockTransMap: Map<string, Record<string, unknown>> | undefined\n if (locale && rows.length > 0) {\n const layoutTransTable = schemaRegistry.get(`${ctx.entity.name}_layout_translations`)\n if (layoutTransTable) {\n const layoutIds = rows.map((r) => r.id as string)\n const translations = await ctx.db\n .select()\n .from(layoutTransTable)\n .where(\n and(\n inArray(layoutTransTable.layoutId, layoutIds),\n eq(layoutTransTable.locale, locale),\n ),\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 if (!result.has(eid)) result.set(eid, {})\n const entityBlocks = result.get(eid)!\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 const localeCondition = locale\n ? isDefault\n ? or(eq(layoutTable.locale, locale), isNull(layoutTable.locale))!\n : eq(layoutTable.locale, locale)\n : isNull(layoutTable.locale)\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 if (!grouped.has(key)) grouped.set(key, { localeRows: [], nullRows: [] })\n const group = grouped.get(key)!\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 if (!result.has(eid)) result.set(eid, {})\n const entityBlocks = result.get(eid)!\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(resolver: ContextResolver | undefined): 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(\n `Forbidden: role '${role}' cannot ${action} '${entity.name}'`,\n )\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(\n ctx: EntityContext,\n scopeId: string,\n): 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 * QueryClient - Read-only client for frontend use\n * SAFE for client bundles - NO 'server-only' import\n */\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { and, eq, type SQL, sql } from 'drizzle-orm'\nimport type { PgTableWithColumns } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { CountCacheLike } from '../count-cache.js'\nimport type { CursorInput } from '../cursor.js'\nimport { buildCursorCondition } from '../cursor.js'\nimport type { Entity } from '../define-entity.js'\nimport { shapeDto, shapeDtos } from '../dto-shaper.js'\nimport type { FieldConfig } from '../fields/base.js'\nimport {\n assertEntityAccess,\n attachBlocks,\n buildCountCacheKey,\n buildScopeCondition,\n getAllFields,\n getBlocksFields,\n getScopeId,\n loadBlocks,\n mergeTranslations,\n type ContextResolver,\n type EntityContext,\n} from '../shared/entity-data-ops.js'\nimport type { InferEntityDTO } from '../types/infer.js'\nimport type { Logger } from '../types/logger.js'\n\nexport interface QueryClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n entity: Entity<AllFields>\n db: PostgresJsDatabase // Read-only connection (enforced at PostgreSQL level)\n logger?: Logger\n /** Optional count cache for COUNT(*) query optimization. */\n countCache?: CountCacheLike\n /** Resolves the current request's security context. */\n contextResolver?: ContextResolver\n}\n\nexport interface FindByIdOptions {\n select?: string[]\n locale?: string\n /** Default content locale. For localized blocks, NULL rows (from initial create)\n * are only returned as fallback when locale matches defaultLocale. */\n defaultLocale?: string\n}\n\nexport interface FindManyOptions {\n where?: SQL | undefined\n limit?: number\n offset?: number\n orderBy?: SQL | SQL[]\n select?: string[]\n locale?: string\n defaultLocale?: string\n /**\n * Cursor-based (keyset) pagination. When provided, replaces OFFSET with a\n * WHERE condition for O(1) page access at any depth. The `offset` option\n * is ignored when `cursor` is set.\n *\n * The cursor `field` must be a real column on the entity table.\n */\n cursor?: CursorInput\n}\n\nexport interface CountOptions {\n where?: SQL | undefined\n}\n\n/**\n * QueryClient - Read-only entity access for frontends\n *\n * **Requires RequestContext.** All operations check permissions and scope via\n * the context established by `runWithContextAsync()`. CLI scripts use `runAsCli()`.\n *\n * Security & integrity layers:\n * 1. Permission enforcement: `assertEntityAccess()` checks `checker(role, entity, 'view')` from context\n * 2. Scope filtering: scoped entities auto-filter by `_scopeId` from context\n * 3. Publish filtering: publishable entities auto-filter to `status = 'published'`\n * 4. Read-only DB connection (PostgreSQL enforces with `default_transaction_read_only=on`)\n * 5. NO mutation methods on TypeScript type (create/update/delete don't exist)\n *\n * @typeParam AllFields - The entity's complete field map. Inferred automatically\n * when constructing via `createQueryClient(entity)`.\n */\nexport class QueryClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private entity: Entity<AllFields>\n private db: PostgresJsDatabase\n private logger?: Logger\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns require PgTableWithColumns<any> for property access\n private table: PgTableWithColumns<any>\n private isPublishable: boolean\n private countCache?: CountCacheLike\n private contextResolver?: ContextResolver\n\n /** Shared context for entity-data-ops functions. */\n private get ctx(): EntityContext {\n return { entity: this.entity, db: this.db, table: this.table, resolveContext: this.contextResolver }\n }\n\n constructor(config: QueryClientConfig<AllFields>) {\n this.entity = config.entity\n this.db = config.db // Assumes read-only connection passed from outside\n this.logger = config.logger\n this.countCache = config.countCache\n this.contextResolver = config.contextResolver\n\n // Detect if entity has publishable behavior\n this.isPublishable = config.entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n\n // Get table from schema registry\n const table = schemaRegistry.get(config.entity.name)\n if (!table) {\n throw new Error(\n `Schema for entity '${config.entity.name}' not found in registry. ` +\n 'Ensure schemas are generated and registered before creating QueryClient.',\n )\n }\n this.table = table\n }\n\n /**\n * Find entity by ID\n * Auto-filters to published if entity has publishable() behavior\n * PHASE 3: Merges translations if locale is provided\n */\n async findById(id: string, options?: FindByIdOptions): Promise<InferEntityDTO<AllFields> | null> {\n this.logger?.info(\n { entity: this.entity.name, id, locale: options?.locale },\n 'Query: Finding entity by ID',\n )\n\n // Permission + scope enforcement\n await assertEntityAccess(this.entity, this.contextResolver, 'view')\n const scopeId = await getScopeId(this.entity, this.contextResolver)\n\n // Query database\n const whereConditions = [eq(this.table.id, id)]\n\n // Scope filter\n if (scopeId) whereConditions.push(buildScopeCondition(this.ctx, scopeId))\n\n // Auto-filter to published if entity has publishable behavior\n if (this.isPublishable) {\n whereConditions.push(this.buildPublishFilter(options?.locale))\n }\n\n const [row] = await this.db\n .select()\n .from(this.table)\n .where(and(...whereConditions))\n\n if (!row) return null\n\n // Shape DTO (row is non-null here, so shaped result will be non-null)\n const shaped = shapeDto(this.entity, row, { select: options?.select }) as Record<\n string,\n unknown\n >\n if (!shaped) return null\n\n // Attach blocks from layout table (with locale for block translations)\n if (getBlocksFields(this.ctx).length > 0) {\n const blocksMap = await loadBlocks(this.ctx, [shaped.id as string], options?.locale, {\n defaultLocale: options?.defaultLocale,\n })\n attachBlocks(this.ctx, [shaped], blocksMap)\n }\n\n // PHASE 3: Merge translations if locale specified\n if (options?.locale) {\n const merged = await mergeTranslations(this.ctx,[shaped], options.locale)\n return merged[0] as InferEntityDTO<AllFields>\n }\n\n return shaped as InferEntityDTO<AllFields>\n }\n\n /**\n * Find multiple entities\n * Auto-filters to published if entity has publishable() behavior\n * PHASE 3: Merges translations if locale is provided\n */\n async findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n this.logger?.info(\n { entity: this.entity.name, options, locale: options?.locale },\n 'Query: Finding entities',\n )\n\n // Permission + scope enforcement\n await assertEntityAccess(this.entity, this.contextResolver, 'view')\n const scopeId = await getScopeId(this.entity, this.contextResolver)\n\n // Build query\n let query = this.db.select().from(this.table).$dynamic()\n\n // Collect WHERE conditions\n const conditions: SQL[] = []\n\n // Scope filter\n if (scopeId) conditions.push(buildScopeCondition(this.ctx, scopeId))\n\n // Auto-filter to published if entity has publishable behavior\n if (this.isPublishable) {\n conditions.push(this.buildPublishFilter(options?.locale))\n }\n\n if (options?.where) {\n conditions.push(options.where)\n }\n\n // Cursor-based pagination: add keyset WHERE condition (replaces OFFSET)\n if (options?.cursor) {\n // Whitelist: cursor field must be a real column on the entity\n const allFields = getAllFields(this.ctx)\n if (!(options.cursor.field in allFields) && options.cursor.field !== 'id') {\n throw new Error(\n `Invalid cursor field: '${options.cursor.field}' is not a field on '${this.entity.name}'`,\n )\n }\n const cursorCondition = buildCursorCondition(this.table, options.cursor)\n if (cursorCondition) {\n conditions.push(cursorCondition)\n }\n }\n\n if (conditions.length > 0) {\n query = query.where(and(...conditions))\n }\n\n if (options?.limit) {\n query = query.limit(options.limit)\n }\n // Cursor takes precedence over offset — skip offset when cursor is active\n if (options?.offset && !options?.cursor) {\n query = query.offset(options.offset)\n }\n if (options?.orderBy) {\n const cols = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy]\n query = query.orderBy(...cols)\n }\n\n const rows = await query\n\n // Shape DTOs (filter out null results)\n const shaped = shapeDtos(this.entity, rows, { select: options?.select }).filter(\n (e): e is NonNullable<typeof e> => e !== null,\n ) as Record<string, unknown>[]\n\n // Attach blocks from layout table (with locale for block translations)\n if (getBlocksFields(this.ctx).length > 0 && shaped.length > 0) {\n const entityIds = shaped.map((e) => e.id as string)\n const blocksMap = await loadBlocks(this.ctx, entityIds, options?.locale, {\n defaultLocale: options?.defaultLocale,\n })\n attachBlocks(this.ctx, shaped, blocksMap)\n }\n\n // PHASE 3: Merge translations if locale specified\n if (options?.locale) {\n return (await mergeTranslations(this.ctx,shaped, options.locale)) as InferEntityDTO<AllFields>[]\n }\n\n return shaped as InferEntityDTO<AllFields>[]\n }\n\n /**\n * Count entities\n * Auto-filters to published if entity has publishable() behavior\n */\n async count(options?: CountOptions): Promise<number> {\n this.logger?.info({ entity: this.entity.name, options }, 'Query: Counting entities')\n\n // Permission + scope enforcement\n await assertEntityAccess(this.entity, this.contextResolver, 'view')\n const scopeId = await getScopeId(this.entity, this.contextResolver)\n\n // Build cache key: entityName + serialized WHERE (or empty for unfiltered)\n const cacheKey = buildCountCacheKey(this.entity.name, options?.where, 'query', scopeId)\n if (this.countCache) {\n const cached = this.countCache.get(cacheKey)\n if (cached !== undefined) {\n this.logger?.debug?.({ entity: this.entity.name, cached }, 'Count cache hit')\n return cached\n }\n }\n\n // Build count query\n let query = this.db.select({ count: sql<number>`count(*)` }).from(this.table).$dynamic()\n\n // Collect conditions: scope + publish filter + user-provided WHERE\n const conditions: SQL[] = []\n if (scopeId) conditions.push(buildScopeCondition(this.ctx, scopeId))\n if (this.isPublishable) conditions.push(this.buildPublishFilter())\n if (options?.where) conditions.push(options.where)\n if (conditions.length > 0) query = query.where(and(...conditions))\n\n const [result] = await query\n const count = Number(result.count)\n\n // Cache the result\n if (this.countCache) {\n this.countCache.set(cacheKey, count)\n }\n\n return count\n }\n\n /**\n * Build publish filter SQL condition.\n * When a locale is provided and a locale_status table exists,\n * uses COALESCE to check locale-specific status first, falling back to base status.\n */\n private buildPublishFilter(locale?: string): SQL {\n if (locale) {\n const localeStatusTable = schemaRegistry.get(`${this.entity.name}_locale_status`)\n if (localeStatusTable) {\n return sql`COALESCE(\n (SELECT ${localeStatusTable.status} FROM ${localeStatusTable}\n WHERE ${localeStatusTable.entityId} = ${this.table.id}\n AND ${localeStatusTable.locale} = ${locale}),\n ${this.table.status}\n ) = 'published'`\n }\n }\n return eq(this.table.status, 'published')\n }\n\n // mergeTranslations, getBlocksFields, loadBlocks, attachBlocks, buildCountCacheKey\n // → extracted to ../shared/entity-data-ops.ts (shared with AdminClient)\n\n // NO create/update/delete methods\n // These physically don't exist on the TypeScript type - security by design\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/cursor.ts","../../src/dto-shaper.ts","../../src/shared/entity-data-ops.ts","../../src/query/client.ts"],"sourcesContent":["/**\n * Cursor-based (keyset) pagination utilities.\n *\n * Cursor pagination avoids the performance cliff of OFFSET at scale (1M+ rows).\n * Instead of `OFFSET N`, it uses a WHERE condition:\n * `WHERE (sortField < lastValue) OR (sortField = lastValue AND id < lastId)`\n * which Postgres can serve from an index in constant time.\n *\n * The cursor is opaque to the client — base64-encoded JSON.\n *\n * Security:\n * - `field` must be whitelisted against the entity's actual fields\n * - `id` must be a valid UUID\n * - `value` is parameterized (never interpolated into SQL)\n * - Malformed cursors return null (caller returns 400)\n */\n\nimport { and, eq, getTableColumns, gt, lt, or, type SQL } from 'drizzle-orm'\nimport type { PgTable } from 'drizzle-orm/pg-core'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Cursor input for keyset pagination. */\nexport interface CursorInput {\n /** Sort field name (e.g. 'createdAt'). Must be a real column on the entity. */\n field: string\n /** Last seen value of the sort field. */\n value: string | number\n /** Sort direction — must match the ORDER BY direction. */\n direction: 'asc' | 'desc'\n /** Tie-breaker: last seen entity ID. Required for non-unique sort fields. */\n id?: string\n}\n\n/** Decoded cursor (internal, after validation). */\ninterface DecodedCursor {\n field: string\n value: string | number\n direction: 'asc' | 'desc'\n id?: string\n}\n\n// ---------------------------------------------------------------------------\n// UUID validation (same format used throughout the toolkit)\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n// ---------------------------------------------------------------------------\n// Encode / decode\n// ---------------------------------------------------------------------------\n\n/**\n * Encode a cursor for API transport (base64url).\n * Built from the last item in a result set.\n */\nexport function encodeCursor(\n item: Record<string, unknown>,\n sortField: string,\n direction: 'asc' | 'desc',\n): string {\n const payload: CursorInput = {\n field: sortField,\n value: item[sortField] as string | number,\n direction,\n id: item.id as string | undefined,\n }\n return btoa(JSON.stringify(payload))\n}\n\n/**\n * Decode and validate a cursor string from query params.\n * Returns null if the cursor is malformed, tampered, or invalid.\n *\n * Security: the `field` value is NOT validated here — the caller must\n * whitelist it against the entity's actual columns.\n */\nexport function decodeCursor(encoded: string): DecodedCursor | null {\n try {\n const json = atob(encoded)\n const parsed: unknown = JSON.parse(json)\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n\n // Validate field\n if (typeof obj.field !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(obj.field)) {\n return null\n }\n\n // Validate value (string or number)\n if (typeof obj.value !== 'string' && typeof obj.value !== 'number') {\n return null\n }\n\n // Validate direction\n if (obj.direction !== 'asc' && obj.direction !== 'desc') {\n return null\n }\n\n // Validate id (optional, must be UUID if present)\n if (obj.id !== undefined) {\n if (typeof obj.id !== 'string' || !UUID_RE.test(obj.id)) {\n return null\n }\n }\n\n return {\n field: obj.field,\n value: obj.value,\n direction: obj.direction,\n id: obj.id as string | undefined,\n }\n } catch {\n return null\n }\n}\n\n// ---------------------------------------------------------------------------\n// SQL condition builder\n// ---------------------------------------------------------------------------\n\n/**\n * Build the keyset WHERE condition from a decoded cursor.\n *\n * For DESC: `WHERE (field < value) OR (field = value AND id < cursorId)`\n * For ASC: `WHERE (field > value) OR (field = value AND id > cursorId)`\n *\n * The caller must verify that `cursor.field` exists on the table before calling.\n *\n * @param table - Drizzle table with columns\n * @param cursor - Decoded and validated cursor\n * @returns SQL condition, or null if the field doesn't exist on the table\n */\nexport function buildCursorCondition(table: PgTable, cursor: DecodedCursor): SQL | null {\n const cols = getTableColumns(table)\n const column = cols[cursor.field]\n if (!column) return null\n\n const isDesc = cursor.direction === 'desc'\n const compare = isDesc ? lt : gt\n\n // Primary condition: sort field passes the cursor value\n const fieldCondition = compare(column, cursor.value)\n\n // Without tie-breaker ID, use simple comparison\n if (!cursor.id) {\n return fieldCondition\n }\n\n // With tie-breaker: (field < value) OR (field = value AND id < cursorId)\n const idColumn = cols.id\n if (!idColumn) return fieldCondition\n\n const idCondition = compare(idColumn, cursor.id)\n // Drizzle's `or`/`and` return SQL when called with at least one defined arg.\n // Fall back to fieldCondition rather than asserting non-null.\n return or(fieldCondition, and(eq(column, cursor.value), idCondition)) ?? fieldCondition\n}\n","/**\n * DTO Shaper\n * Transforms raw DB rows into clean DTOs.\n * All fields are real columns — just read from row directly.\n */\n\nimport type { Entity } from './define-entity.js'\nimport type { FieldConfig } from './fields/base.js'\nimport type { InferEntityDTO } from './types/infer.js'\n\nexport interface ShapeDtoOptions {\n /** Explicit field selection. */\n select?: string[]\n /**\n * Include internal fields in the output. Two rules apply:\n * 1. Fields marked `internal: true` in their config (the canonical flag).\n * 2. Legacy infrastructure columns whose name starts with `_` and are not\n * declared in `entity.allFields` (e.g. `_version`, `_scopeId` added by\n * the schema generator).\n *\n * Set this on trusted server reads that need to inspect or transition\n * state-machine fields (e.g. the workflow route reading the current\n * `_workflowStatus` before deciding the next state).\n */\n includeInternal?: boolean\n}\n\n/**\n * Shape a raw DB row into a typed DTO.\n * Every field is a real column — read directly from row.\n */\nexport function shapeDto<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n row: Record<string, unknown>,\n options?: ShapeDtoOptions,\n): InferEntityDTO<AllFields> | null {\n if (!row) return null\n\n const result: Record<string, unknown> = {}\n const fieldsToInclude = options?.select || Object.keys(entity.allFields)\n\n for (const fieldName of fieldsToInclude) {\n const fieldConfig = (entity.allFields as Record<string, FieldConfig>)[fieldName]\n\n if (!options?.includeInternal) {\n // Two complementary internal-field rules, both bypassed by `includeInternal`:\n // 1. Naming convention: any field whose name starts with `_` (legacy\n // infrastructure columns like `_version`, `_scopeId`, plus any\n // `_workflowStatus`-style behavior field).\n // 2. Explicit flag: `internal: true` on the field config (the canonical\n // flag — works for fields that don't follow the underscore convention).\n const internalByName = fieldName.startsWith('_')\n const internalByFlag = fieldConfig?.internal === true\n if (internalByName || internalByFlag) continue\n }\n\n if (!fieldConfig) continue\n\n // Blocks are loaded separately from layout tables\n if (fieldConfig.type === 'blocks') continue\n\n result[fieldName] = row[fieldName]\n }\n\n return result as InferEntityDTO<AllFields>\n}\n\n/**\n * Shape multiple rows\n */\nexport function shapeDtos<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n rows: Record<string, unknown>[],\n options?: ShapeDtoOptions,\n): (InferEntityDTO<AllFields> | null)[] {\n return rows.map((row) => shapeDto(entity, row, options))\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\n}\n\n// ---------------------------------------------------------------------------\n// Field accessors\n// ---------------------------------------------------------------------------\n\n/** Get the entity's full field map with proper typing. Eliminates repeated casts. */\nexport function getAllFields(ctx: EntityContext): Record<string, FieldConfig> {\n return ctx.entity.allFields as Record<string, FieldConfig>\n}\n\n/** Get all blocks field definitions for this entity. */\nexport function getBlocksFields(ctx: EntityContext): Array<{ name: string; config: FieldConfig }> {\n return Object.entries(getAllFields(ctx))\n .filter(([_, config]) => config.type === 'blocks')\n .map(([name, config]) => ({ name, config }))\n}\n\n// ---------------------------------------------------------------------------\n// Translation merging\n// ---------------------------------------------------------------------------\n\n/**\n * Merge translations into entities for the specified locale.\n * Reads translatable field values from real columns on the translation row.\n */\nexport async function mergeTranslations<T extends Record<string, unknown>>(\n ctx: EntityContext,\n entities: T[],\n locale: string,\n): Promise<T[]> {\n if (!entities.length) return entities\n\n const translationTableName = `${ctx.entity.name}_translations`\n const translationTable = schemaRegistry.get(translationTableName)\n\n if (!translationTable) return entities\n\n const entityIds = entities.map((e) => e.id)\n\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(and(inArray(translationTable.entityId, entityIds), eq(translationTable.locale, locale)))\n\n // Get translatable field names\n const translatableFields = Object.entries(getAllFields(ctx))\n .filter(([_, config]) => config.translatable)\n .map(([name]) => name)\n\n const translationMap = new Map<unknown, Record<string, unknown>>()\n for (const translation of translations) {\n const translatedValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n const value = (translation as Record<string, unknown>)[fieldName]\n if (value !== undefined && value !== null) {\n translatedValues[fieldName] = value\n }\n }\n translationMap.set(translation.entityId, translatedValues)\n }\n\n return entities.map((entity) => {\n const translatedFields = translationMap.get(entity.id)\n if (!translatedFields) return entity\n return { ...entity, ...translatedFields }\n })\n}\n\n// ---------------------------------------------------------------------------\n// Block loading\n// ---------------------------------------------------------------------------\n\nexport interface LoadBlocksOptions {\n defaultLocale?: string\n /**\n * When true (admin editing), clears untranslated translatable block fields\n * to empty string — signals incomplete translation in the editing UI.\n * When false (visitor-facing), preserves base data as fallback.\n */\n strictTranslations?: boolean\n}\n\n/**\n * Load blocks for one or more entities from the layout table.\n *\n * Handles both block translation modes:\n * - Shared layout (localized: false): loads locale=NULL rows, merges translations\n * - Per-locale layout (localized: true): loads rows matching the provided locale only\n */\nexport async function loadBlocks(\n ctx: EntityContext,\n entityIds: string[],\n locale?: string,\n options?: LoadBlocksOptions,\n): Promise<Map<string, Record<string, unknown[]>>> {\n const blocksFields = getBlocksFields(ctx)\n if (blocksFields.length === 0 || entityIds.length === 0) {\n return new Map()\n }\n\n const layoutTable = schemaRegistry.get(`${ctx.entity.name}_layout`)\n if (!layoutTable) return new Map()\n\n // Determine block field modes\n const sharedFields = blocksFields.filter(\n ({ config }) => !('localized' in config && config.localized),\n )\n const localizedFields = blocksFields.filter(\n ({ config }) => 'localized' in config && config.localized,\n )\n\n const result = new Map<string, Record<string, unknown[]>>()\n\n // ---- Shared blocks: locale IS NULL, translations from layout_translations ----\n if (sharedFields.length > 0) {\n const rows = await ctx.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), isNull(layoutTable.locale)))\n .orderBy(layoutTable.sortOrder)\n\n // Load translations if locale provided\n let blockTransMap: Map<string, Record<string, unknown>> | undefined\n if (locale && rows.length > 0) {\n const layoutTransTable = schemaRegistry.get(`${ctx.entity.name}_layout_translations`)\n if (layoutTransTable) {\n const layoutIds = rows.map((r) => r.id as string)\n const translations = await ctx.db\n .select()\n .from(layoutTransTable)\n .where(\n and(inArray(layoutTransTable.layoutId, layoutIds), eq(layoutTransTable.locale, locale)),\n )\n\n blockTransMap = new Map()\n for (const t of translations) {\n blockTransMap.set(t.layoutId as string, (t.fields as Record<string, unknown>) ?? {})\n }\n }\n }\n\n // Build block definition lookup for translatable field clearing (strict mode only)\n let blockDefMap: Map<string, Record<string, FieldConfig>> | undefined\n if (options?.strictTranslations) {\n blockDefMap = new Map()\n for (const { config } of sharedFields) {\n if (config.type !== 'blocks') continue\n for (const def of config.blocks) {\n blockDefMap.set(def.slug, def.fields)\n }\n }\n }\n\n for (const row of rows) {\n const eid = row.entityId as string\n const fname = row.fieldName as string\n\n let entityBlocks = result.get(eid)\n if (!entityBlocks) {\n entityBlocks = {}\n result.set(eid, entityBlocks)\n }\n if (!entityBlocks[fname]) entityBlocks[fname] = []\n\n const blockData = (row.data as Record<string, unknown>) ?? {}\n const translatedFields = blockTransMap?.get(row.id as string)\n\n // Build the block object\n const block: Record<string, unknown> = {\n _block: row.blockType,\n _id: row.id,\n ...blockData,\n ...(translatedFields ?? {}),\n }\n\n // Strict mode: clear untranslated translatable fields when loading for non-default locale\n if (locale && blockDefMap) {\n const blockType = row.blockType as string\n const fieldDefs = blockDefMap.get(blockType)\n if (fieldDefs) {\n for (const [fieldName, fieldConfig] of Object.entries(fieldDefs)) {\n if (fieldConfig.translatable && !translatedFields?.[fieldName]) {\n block[fieldName] = ''\n }\n }\n }\n }\n\n entityBlocks[fname].push(block)\n }\n }\n\n // ---- Localized blocks: filter by locale column ----\n // NULL rows (from initial create) only fall back for the default locale.\n // Non-default locales without locale-specific rows get an empty array.\n if (localizedFields.length > 0) {\n const isDefault = !locale || locale === options?.defaultLocale\n let localeCondition: SQL\n if (locale) {\n if (isDefault) {\n // Both args defined → drizzle's `or` always returns SQL; fall back defensively\n localeCondition =\n or(eq(layoutTable.locale, locale), isNull(layoutTable.locale)) ??\n eq(layoutTable.locale, locale)\n } else {\n localeCondition = eq(layoutTable.locale, locale)\n }\n } else {\n localeCondition = isNull(layoutTable.locale)\n }\n\n const rows = await ctx.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), localeCondition))\n .orderBy(layoutTable.sortOrder)\n\n // Group by entityId + fieldName, prefer locale-specific rows over NULL\n const grouped = new Map<string, { localeRows: typeof rows; nullRows: typeof rows }>()\n for (const row of rows) {\n const key = `${row.entityId}::${row.fieldName}`\n let group = grouped.get(key)\n if (!group) {\n group = { localeRows: [], nullRows: [] }\n grouped.set(key, group)\n }\n if (row.locale) {\n group.localeRows.push(row)\n } else {\n group.nullRows.push(row)\n }\n }\n\n for (const [, { localeRows, nullRows }] of grouped) {\n // Use locale-specific rows if available, otherwise fall back to NULL\n const effective = localeRows.length > 0 ? localeRows : nullRows\n\n for (const row of effective) {\n const eid = row.entityId as string\n const fname = row.fieldName as string\n\n let entityBlocks = result.get(eid)\n if (!entityBlocks) {\n entityBlocks = {}\n result.set(eid, entityBlocks)\n }\n if (!entityBlocks[fname]) entityBlocks[fname] = []\n\n const blockData = (row.data as Record<string, unknown>) ?? {}\n\n entityBlocks[fname].push({\n _block: row.blockType,\n _id: row.id,\n ...blockData,\n })\n }\n }\n }\n\n return result\n}\n\n/**\n * Attach loaded blocks to shaped DTOs.\n */\nexport function attachBlocks(\n ctx: EntityContext,\n entities: Record<string, unknown>[],\n blocksMap: Map<string, Record<string, unknown[]>>,\n): void {\n const blocksFields = getBlocksFields(ctx)\n if (blocksFields.length === 0) return\n\n for (const entity of entities) {\n const eid = entity.id as string\n const entityBlocks = blocksMap.get(eid) ?? {}\n\n for (const { name } of blocksFields) {\n entity[name] = entityBlocks[name] ?? []\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Count cache key\n// ---------------------------------------------------------------------------\n\n/**\n * Build a cache key for a count query.\n * @param prefix - Optional namespace prefix (e.g. 'query' for QueryClient)\n * @param scopeId - Optional scope ID for multi-tenant isolation\n */\nexport function buildCountCacheKey(\n entityName: string,\n where?: SQL,\n prefix?: string,\n scopeId?: string,\n): string {\n let base = prefix ? `${prefix}:${entityName}` : entityName\n if (scopeId) base = `${base}@${scopeId}`\n if (!where) return base\n // Use SQL's toString() representation as a stable key.\n // This is a display string, not executed — safe for cache keying.\n return `${base}:${String(where)}`\n}\n\n// ---------------------------------------------------------------------------\n// ForbiddenError\n// ---------------------------------------------------------------------------\n\n/**\n * Thrown when a user lacks permission for an entity operation.\n * Consumers (api-handler) catch this and return HTTP 403.\n */\nexport class ForbiddenError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'ForbiddenError'\n }\n}\n\n// ---------------------------------------------------------------------------\n// Context helpers — dynamic imports to avoid entity → core circular dep\n// ---------------------------------------------------------------------------\n\n// ---------------------------------------------------------------------------\n// Context resolution — framework-agnostic\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve the security context from the resolver on EntityContext.\n * Throws ForbiddenError if no resolver is configured or if it returns undefined.\n */\nasync function resolveSecurityContext(\n resolver: ContextResolver | undefined,\n): Promise<SecurityContext> {\n if (!resolver) {\n throw new ForbiddenError(\n 'No context resolver configured on AdminClient/QueryClient. ' +\n 'Use createAdminClient() from @murumets-ee/core/clients, or pass a contextResolver in config.',\n )\n }\n\n const ctx = await resolver()\n\n if (!ctx) {\n throw new ForbiddenError(\n 'Context resolver returned no context. ' +\n 'Ensure auth is configured in your request handler, or use runAsCli() for CLI scripts.',\n )\n }\n\n return ctx\n}\n\n// ---------------------------------------------------------------------------\n// Permission enforcement\n// ---------------------------------------------------------------------------\n\n/**\n * Assert the current user has permission for the given action on the entity.\n * Uses the PermissionChecker from the resolved SecurityContext.\n */\nexport async function assertEntityAccess(\n entity: Entity,\n resolver: ContextResolver | undefined,\n action: 'view' | 'create' | 'update' | 'delete',\n): Promise<void> {\n const ctx = await resolveSecurityContext(resolver)\n\n const role = ctx.user.groups[0]\n if (!role) {\n throw new ForbiddenError(`User '${ctx.user.id}' has no role assigned.`)\n }\n\n if (!ctx.checker(role, entity.name, action)) {\n throw new ForbiddenError(`Forbidden: role '${role}' cannot ${action} '${entity.name}'`)\n }\n}\n\n// ---------------------------------------------------------------------------\n// Scope filtering\n// ---------------------------------------------------------------------------\n\n/**\n * Get the current scope ID for a scoped entity.\n * Returns undefined for global entities.\n */\nexport async function getScopeId(\n entity: Entity,\n resolver: ContextResolver | undefined,\n): Promise<string | undefined> {\n if (!entity.scope || entity.scope === 'global') return undefined\n\n const ctx = await resolveSecurityContext(resolver)\n return ctx.scope?.id\n}\n\n/**\n * Build a WHERE condition for scope filtering.\n */\nexport function buildScopeCondition(ctx: EntityContext, scopeId: string): SQL {\n return eq(ctx.table._scopeId, scopeId)\n}\n\n/**\n * Require scope for a scoped entity. Throws if no scope is in context.\n * Returns undefined for global entities.\n */\nexport async function requireScopeForEntity(\n entity: Entity,\n resolver: ContextResolver | undefined,\n): Promise<string | undefined> {\n if (!entity.scope || entity.scope === 'global') return undefined\n\n const ctx = await resolveSecurityContext(resolver)\n const scopeId = ctx.scope?.id\n\n if (!scopeId) {\n throw new ForbiddenError(\n `Entity '${entity.name}' requires scope '${entity.scope}' but no scope is set in context.`,\n )\n }\n\n return scopeId\n}\n\n// ---------------------------------------------------------------------------\n// Combined auth context resolution (single resolver call)\n// ---------------------------------------------------------------------------\n\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 */\nexport async function resolveAuthContext(\n entity: Entity,\n resolver: ContextResolver | undefined,\n action: 'view' | 'create' | 'update' | 'delete',\n options?: { strictScope?: boolean },\n): Promise<ResolvedAuthContext> {\n const security = await resolveSecurityContext(resolver)\n\n // Permission check — same logic as assertEntityAccess()\n const role = security.user.groups[0]\n if (!role) {\n throw new ForbiddenError(`User '${security.user.id}' has no role assigned.`)\n }\n if (!security.checker(role, entity.name, action)) {\n throw new ForbiddenError(`Forbidden: role '${role}' cannot ${action} '${entity.name}'`)\n }\n\n // Scope resolution — same logic as getScopeId()/requireScopeForEntity()\n let scopeId: string | undefined\n if (entity.scope && entity.scope !== 'global') {\n scopeId = security.scope?.id\n if (!scopeId && options?.strictScope) {\n throw new ForbiddenError(\n `Entity '${entity.name}' requires scope '${entity.scope}' but no scope is set in context.`,\n )\n }\n }\n\n const behaviorCtx: BehaviorContext = {\n user: { id: security.user.id, name: security.user.name, email: security.user.email },\n }\n\n return { security, scopeId, behaviorCtx }\n}\n","/**\n * QueryClient - Read-only client for frontend use\n * SAFE for client bundles - NO 'server-only' import\n */\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { and, eq, type SQL, sql } from 'drizzle-orm'\nimport type { PgTableWithColumns } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { CountCacheLike } from '../count-cache.js'\nimport type { CursorInput } from '../cursor.js'\nimport { buildCursorCondition } from '../cursor.js'\nimport type { Entity } from '../define-entity.js'\nimport { shapeDto, shapeDtos } from '../dto-shaper.js'\nimport type { FieldConfig } from '../fields/base.js'\nimport {\n attachBlocks,\n buildCountCacheKey,\n buildScopeCondition,\n type ContextResolver,\n type EntityContext,\n getAllFields,\n getBlocksFields,\n loadBlocks,\n mergeTranslations,\n resolveAuthContext,\n} from '../shared/entity-data-ops.js'\nimport type { InferEntityDTO } from '../types/infer.js'\nimport type { Logger } from '../types/logger.js'\n\nexport interface QueryClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n entity: Entity<AllFields>\n db: PostgresJsDatabase // Read-only connection (enforced at PostgreSQL level)\n logger?: Logger\n /** Optional count cache for COUNT(*) query optimization. */\n countCache?: CountCacheLike\n /** Resolves the current request's security context. */\n contextResolver?: ContextResolver\n}\n\nexport interface FindByIdOptions {\n select?: string[]\n locale?: string\n /** Default content locale. For localized blocks, NULL rows (from initial create)\n * are only returned as fallback when locale matches defaultLocale. */\n defaultLocale?: string\n}\n\nexport interface FindManyOptions {\n where?: SQL | undefined\n limit?: number\n offset?: number\n orderBy?: SQL | SQL[]\n select?: string[]\n locale?: string\n defaultLocale?: string\n /**\n * Cursor-based (keyset) pagination. When provided, replaces OFFSET with a\n * WHERE condition for O(1) page access at any depth. The `offset` option\n * is ignored when `cursor` is set.\n *\n * The cursor `field` must be a real column on the entity table.\n */\n cursor?: CursorInput\n}\n\nexport interface CountOptions {\n where?: SQL | undefined\n}\n\n/**\n * QueryClient - Read-only entity access for frontends\n *\n * **Requires RequestContext.** All operations check permissions and scope via\n * the context established by `runWithContextAsync()`. CLI scripts use `runAsCli()`.\n *\n * Security & integrity layers:\n * 1. Permission enforcement: `assertEntityAccess()` checks `checker(role, entity, 'view')` from context\n * 2. Scope filtering: scoped entities auto-filter by `_scopeId` from context\n * 3. Publish filtering: publishable entities auto-filter to `status = 'published'`\n * 4. Read-only DB connection (PostgreSQL enforces with `default_transaction_read_only=on`)\n * 5. NO mutation methods on TypeScript type (create/update/delete don't exist)\n *\n * @typeParam AllFields - The entity's complete field map. Inferred automatically\n * when constructing via `createQueryClient(entity)`.\n */\nexport class QueryClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private entity: Entity<AllFields>\n private db: PostgresJsDatabase\n private logger?: Logger\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns require PgTableWithColumns<any> for property access\n private table: PgTableWithColumns<any>\n private isPublishable: boolean\n private countCache?: CountCacheLike\n private contextResolver?: ContextResolver\n\n /** Shared context for entity-data-ops functions. */\n private get ctx(): EntityContext {\n return {\n entity: this.entity,\n db: this.db,\n table: this.table,\n resolveContext: this.contextResolver,\n }\n }\n\n constructor(config: QueryClientConfig<AllFields>) {\n this.entity = config.entity\n this.db = config.db // Assumes read-only connection passed from outside\n this.logger = config.logger\n this.countCache = config.countCache\n this.contextResolver = config.contextResolver\n\n // Detect if entity has publishable behavior\n this.isPublishable = config.entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n\n // Get table from schema registry\n const table = schemaRegistry.get(config.entity.name)\n if (!table) {\n throw new Error(\n `Schema for entity '${config.entity.name}' not found in registry. ` +\n 'Ensure schemas are generated and registered before creating QueryClient.',\n )\n }\n this.table = table\n }\n\n /**\n * Find entity by ID\n * Auto-filters to published if entity has publishable() behavior\n * PHASE 3: Merges translations if locale is provided\n */\n async findById(id: string, options?: FindByIdOptions): Promise<InferEntityDTO<AllFields> | null> {\n this.logger?.info(\n { entity: this.entity.name, id, locale: options?.locale },\n 'Query: Finding entity by ID',\n )\n\n // Permission + scope (one resolver call; reads don't need strict scope)\n const { scopeId } = await resolveAuthContext(this.entity, this.contextResolver, 'view')\n\n // Query database\n const whereConditions = [eq(this.table.id, id)]\n\n // Scope filter\n if (scopeId) whereConditions.push(buildScopeCondition(this.ctx, scopeId))\n\n // Auto-filter to published if entity has publishable behavior\n if (this.isPublishable) {\n whereConditions.push(this.buildPublishFilter(options?.locale))\n }\n\n const [row] = await this.db\n .select()\n .from(this.table)\n .where(and(...whereConditions))\n\n if (!row) return null\n\n // Shape DTO (row is non-null here, so shaped result will be non-null)\n const shaped = shapeDto(this.entity, row, { select: options?.select }) as Record<\n string,\n unknown\n >\n if (!shaped) return null\n\n // Attach blocks from layout table (with locale for block translations)\n if (getBlocksFields(this.ctx).length > 0) {\n const blocksMap = await loadBlocks(this.ctx, [shaped.id as string], options?.locale, {\n defaultLocale: options?.defaultLocale,\n })\n attachBlocks(this.ctx, [shaped], blocksMap)\n }\n\n // PHASE 3: Merge translations if locale specified\n if (options?.locale) {\n const merged = await mergeTranslations(this.ctx, [shaped], options.locale)\n return merged[0] as InferEntityDTO<AllFields>\n }\n\n return shaped as InferEntityDTO<AllFields>\n }\n\n /**\n * Find multiple entities\n * Auto-filters to published if entity has publishable() behavior\n * PHASE 3: Merges translations if locale is provided\n */\n async findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n this.logger?.info(\n { entity: this.entity.name, options, locale: options?.locale },\n 'Query: Finding entities',\n )\n\n // Permission + scope (one resolver call; reads don't need strict scope)\n const { scopeId } = await resolveAuthContext(this.entity, this.contextResolver, 'view')\n\n // Build query\n let query = this.db.select().from(this.table).$dynamic()\n\n // Collect WHERE conditions\n const conditions: SQL[] = []\n\n // Scope filter\n if (scopeId) conditions.push(buildScopeCondition(this.ctx, scopeId))\n\n // Auto-filter to published if entity has publishable behavior\n if (this.isPublishable) {\n conditions.push(this.buildPublishFilter(options?.locale))\n }\n\n if (options?.where) {\n conditions.push(options.where)\n }\n\n // Cursor-based pagination: add keyset WHERE condition (replaces OFFSET)\n if (options?.cursor) {\n // Whitelist: cursor field must be a real column on the entity\n const allFields = getAllFields(this.ctx)\n if (!(options.cursor.field in allFields) && options.cursor.field !== 'id') {\n throw new Error(\n `Invalid cursor field: '${options.cursor.field}' is not a field on '${this.entity.name}'`,\n )\n }\n const cursorCondition = buildCursorCondition(this.table, options.cursor)\n if (cursorCondition) {\n conditions.push(cursorCondition)\n }\n }\n\n if (conditions.length > 0) {\n query = query.where(and(...conditions))\n }\n\n if (options?.limit) {\n query = query.limit(options.limit)\n }\n // Cursor takes precedence over offset — skip offset when cursor is active\n if (options?.offset && !options?.cursor) {\n query = query.offset(options.offset)\n }\n if (options?.orderBy) {\n const cols = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy]\n query = query.orderBy(...cols)\n }\n\n const rows = await query\n\n // Shape DTOs (filter out null results)\n const shaped = shapeDtos(this.entity, rows, { select: options?.select }).filter(\n (e): e is NonNullable<typeof e> => e !== null,\n ) as Record<string, unknown>[]\n\n // Attach blocks from layout table (with locale for block translations)\n if (getBlocksFields(this.ctx).length > 0 && shaped.length > 0) {\n const entityIds = shaped.map((e) => e.id as string)\n const blocksMap = await loadBlocks(this.ctx, entityIds, options?.locale, {\n defaultLocale: options?.defaultLocale,\n })\n attachBlocks(this.ctx, shaped, blocksMap)\n }\n\n // PHASE 3: Merge translations if locale specified\n if (options?.locale) {\n return (await mergeTranslations(\n this.ctx,\n shaped,\n options.locale,\n )) as InferEntityDTO<AllFields>[]\n }\n\n return shaped as InferEntityDTO<AllFields>[]\n }\n\n /**\n * Count entities\n * Auto-filters to published if entity has publishable() behavior\n */\n async count(options?: CountOptions): Promise<number> {\n this.logger?.info({ entity: this.entity.name, options }, 'Query: Counting entities')\n\n // Permission + scope (one resolver call; reads don't need strict scope)\n const { scopeId } = await resolveAuthContext(this.entity, this.contextResolver, 'view')\n\n // Build cache key: entityName + serialized WHERE (or empty for unfiltered)\n const cacheKey = buildCountCacheKey(this.entity.name, options?.where, 'query', scopeId)\n\n const compute = async (): Promise<number> => {\n let query = this.db.select({ count: sql<number>`count(*)` }).from(this.table).$dynamic()\n // Collect conditions: scope + publish filter + user-provided WHERE\n const conditions: SQL[] = []\n if (scopeId) conditions.push(buildScopeCondition(this.ctx, scopeId))\n if (this.isPublishable) conditions.push(this.buildPublishFilter())\n if (options?.where) conditions.push(options.where)\n if (conditions.length > 0) query = query.where(and(...conditions))\n const [result] = await query\n return Number(result.count)\n }\n\n // No cache configured → run the query directly (back-compat).\n if (!this.countCache) return compute()\n\n // Single-flight: concurrent callers crossing the TTL gap share one query.\n return this.countCache.getOrCompute(cacheKey, compute)\n }\n\n /**\n * Build publish filter SQL condition.\n * When a locale is provided and a locale_status table exists,\n * uses COALESCE to check locale-specific status first, falling back to base status.\n */\n private buildPublishFilter(locale?: string): SQL {\n if (locale) {\n const localeStatusTable = schemaRegistry.get(`${this.entity.name}_locale_status`)\n if (localeStatusTable) {\n return sql`COALESCE(\n (SELECT ${localeStatusTable.status} FROM ${localeStatusTable}\n WHERE ${localeStatusTable.entityId} = ${this.table.id}\n AND ${localeStatusTable.locale} = ${locale}),\n ${this.table.status}\n ) = 'published'`\n }\n }\n return eq(this.table.status, 'published')\n }\n\n // mergeTranslations, getBlocksFields, loadBlocks, attachBlocks, buildCountCacheKey\n // → extracted to ../shared/entity-data-ops.ts (shared with AdminClient)\n\n // NO create/update/delete methods\n // These physically don't exist on the TypeScript type - security by design\n}\n"],"mappings":"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"}
|
package/dist/refs/index.d.mts
CHANGED
|
@@ -36,6 +36,23 @@ interface BaseFieldConfig {
|
|
|
36
36
|
translatable?: boolean;
|
|
37
37
|
indexed?: boolean;
|
|
38
38
|
unique?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Marks the field as **system-managed**: stored as a real column, populated
|
|
41
|
+
* by behavior hooks or trusted server-side transitions, but NOT writable
|
|
42
|
+
* through the public `AdminClient.create/update` surface.
|
|
43
|
+
*
|
|
44
|
+
* - Caller-supplied values for internal fields are silently stripped before
|
|
45
|
+
* hooks run (so an HTTP PATCH cannot poison a workflow state).
|
|
46
|
+
* - `beforeCreate` / `beforeUpdate` hooks may still set them (they run after
|
|
47
|
+
* the strip), and the values are preserved through validation.
|
|
48
|
+
* - Trusted server code that needs to write internals directly — e.g.
|
|
49
|
+
* workflow transitions invoked from an authorized admin route — must use
|
|
50
|
+
* `AdminClient.updateInternal()`, which bypasses the strip.
|
|
51
|
+
*
|
|
52
|
+
* Use this flag on any field added by a behavior whose value represents a
|
|
53
|
+
* controlled state machine (e.g. `_workflowStatus`), not user input.
|
|
54
|
+
*/
|
|
55
|
+
internal?: boolean;
|
|
39
56
|
access?: {
|
|
40
57
|
view?: string;
|
|
41
58
|
edit?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/refs/find-usages.ts","../../src/refs/errors.ts","../../src/fields/base.ts","../../src/refs/extract-refs.ts","../../src/refs/schema.ts"],"mappings":";;;;UAUiB,WAAA;EACf,YAAA;EACA,QAAA;EACA,WAAA;AAAA;;;AAWF;;;;;;iBAAsB,gBAAA,CACpB,YAAA,UACA,QAAA,UACA,EAAA,EAAI,kBAAA,GACH,OAAA,CAAQ,WAAA;;;cCtBE,qBAAA,SAA8B,KAAA;EAAA,SACzB,UAAA;EAAA,SACA,QAAA;EAAA,SACA,MAAA,EAAQ,WAAA;cAEZ,UAAA,UAAoB,QAAA,UAAkB,MAAA,EAAQ,WAAA;AAAA;;;;;;;UCN3C,eAAA;EACf,QAAA;EACA,OAAA;EACA,YAAA;EACA,OAAA;EACA,MAAA;EACA,MAAA;IACE,IAAA;IACA,IAAA;EAAA;AAAA;AAAA,UAIa,OAAA,SAAgB,eAAA;EAC/B,IAAA;AAAA;AAAA,UAGe,SAAA,SAAkB,eAAA;EACjC,IAAA;EACA,SAAA;EACA,SAAA;EACA,OAAA,GAAU,MAAA;AAAA;AAAA,UAGK,WAAA,SAAoB,eAAA;EACnC,IAAA;EACA,GAAA;EACA,GAAA;EACA,OAAA;AAAA;AAAA,UAGe,YAAA,SAAqB,eAAA;EACpC,IAAA;AAAA;AAAA,UAGe,SAAA,SAAkB,eAAA;EACjC,IAAA;EACA,OAAA,GAAU,IAAA;EACV,OAAA,GAAU,IAAA;AAAA;AAAA,UAGK,WAAA,SAAoB,eAAA;EACnC,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,cAAA,SAAuB,eAAA;EACtC,IAAA;EACA,MAAA;EACA,WAAA;EACA,QAAA;AAAA;AAAA,UAGe,UAAA,SAAmB,eAAA;EAClC,IAAA;EACA,MAAA;EACA,OAAA;AAAA;AAAA,UAGe,aAAA,SAAsB,eAAA;EACrC,IAAA;EACA,MAAA;AAAA;AAAA,UAGe,SAAA,SAAkB,eAAA;EACjC,IAAA;EACA,IAAA;EACA,MAAA;AAAA;;;;;UAOe,kBAAA;EACf,IAAA;EACA,MAAA,EAAQ,MAAA,SAAe,WAAA;AAAA
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/refs/find-usages.ts","../../src/refs/errors.ts","../../src/fields/base.ts","../../src/refs/extract-refs.ts","../../src/refs/schema.ts"],"mappings":";;;;UAUiB,WAAA;EACf,YAAA;EACA,QAAA;EACA,WAAA;AAAA;;;AAWF;;;;;;iBAAsB,gBAAA,CACpB,YAAA,UACA,QAAA,UACA,EAAA,EAAI,kBAAA,GACH,OAAA,CAAQ,WAAA;;;cCtBE,qBAAA,SAA8B,KAAA;EAAA,SACzB,UAAA;EAAA,SACA,QAAA;EAAA,SACA,MAAA,EAAQ,WAAA;cAEZ,UAAA,UAAoB,QAAA,UAAkB,MAAA,EAAQ,WAAA;AAAA;;;;;;;UCN3C,eAAA;EACf,QAAA;EACA,OAAA;EACA,YAAA;EACA,OAAA;EACA,MAAA;EFGA;;;AAWF;;;;;;;;;;;;;EEGE,QAAA;EACA,MAAA;IACE,IAAA;IACA,IAAA;EAAA;AAAA;AAAA,UAIa,OAAA,SAAgB,eAAA;EAC/B,IAAA;AAAA;AAAA,UAGe,SAAA,SAAkB,eAAA;EACjC,IAAA;EACA,SAAA;EACA,SAAA;EACA,OAAA,GAAU,MAAA;AAAA;AAAA,UAGK,WAAA,SAAoB,eAAA;EACnC,IAAA;EACA,GAAA;EACA,GAAA;EACA,OAAA;AAAA;AAAA,UAGe,YAAA,SAAqB,eAAA;EACpC,IAAA;AAAA;AAAA,UAGe,SAAA,SAAkB,eAAA;EACjC,IAAA;EACA,OAAA,GAAU,IAAA;EACV,OAAA,GAAU,IAAA;AAAA;AAAA,UAGK,WAAA,SAAoB,eAAA;EACnC,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,cAAA,SAAuB,eAAA;EACtC,IAAA;EACA,MAAA;EACA,WAAA;EACA,QAAA;AAAA;AAAA,UAGe,UAAA,SAAmB,eAAA;EAClC,IAAA;EACA,MAAA;EACA,OAAA;AAAA;AAAA,UAGe,aAAA,SAAsB,eAAA;EACrC,IAAA;EACA,MAAA;AAAA;AAAA,UAGe,SAAA,SAAkB,eAAA;EACjC,IAAA;EACA,IAAA;EACA,MAAA;AAAA;;;;;UAOe,kBAAA;EACf,IAAA;EACA,MAAA,EAAQ,MAAA,SAAe,WAAA;AAAA;AApDzB;;;;;;;;AAAA,UA+DiB,SAAA,SAAkB,eAAA;EACjC,IAAA;AAAA;AAAA,UAGe,WAAA,SAAoB,eAAA;EACnC,IAAA;EACA,MAAA,WAAiB,kBAAA;EACjB,GAAA;EACA,GAAA;EACA,SAAA;AAAA;AAAA,KAGU,WAAA,GACR,OAAA,GACA,SAAA,GACA,WAAA,GACA,YAAA,GACA,SAAA,GACA,WAAA,GACA,cAAA,GACA,UAAA,GACA,aAAA,GACA,SAAA,GACA,SAAA,GACA,WAAA;;;UCtHa,YAAA;EACf,WAAA;EACA,YAAA;EACA,QAAA;AAAA;;;;;;;;iBAUc,WAAA,CACd,SAAA,EAAW,MAAA,SAAe,WAAA,GAC1B,IAAA,EAAM,MAAA,oBACL,YAAA;;;;;iBAsFa,mBAAA,CAAoB,SAAA,EAAW,MAAA,SAAe,WAAA;;;;;;;AH1G9D;;;;;cIEa,UAAA,yBAAU,kBAAA;;;;kBAoBtB,sBAAA,CAAA,QAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/entity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -28,13 +28,16 @@
|
|
|
28
28
|
"drizzle-orm": "^0.45.2",
|
|
29
29
|
"zod": "^3.24.1",
|
|
30
30
|
"server-only": "^0.0.1",
|
|
31
|
-
"@murumets-ee/db": "0.
|
|
31
|
+
"@murumets-ee/db": "0.11.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"tsdown": "^0.21.7",
|
|
35
35
|
"typescript": "^5.7.2",
|
|
36
36
|
"vitest": "^2.1.8"
|
|
37
37
|
},
|
|
38
|
+
"typeCoverage": {
|
|
39
|
+
"atLeast": 98.99
|
|
40
|
+
},
|
|
38
41
|
"scripts": {
|
|
39
42
|
"build": "tsdown",
|
|
40
43
|
"dev": "tsdown --watch",
|