uql-orm 0.31.3 → 0.31.4

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/README.md CHANGED
@@ -37,7 +37,7 @@ That is the whole install ([setup](https://uql-orm.dev/getting-started)), and th
37
37
  <a href="https://uql-orm.dev">
38
38
  <picture>
39
39
  <source media="(prefers-color-scheme: dark)" srcset="https://uql-orm.dev/demo-dark.webp">
40
- <img src="https://uql-orm.dev/demo-light.webp" alt="A UQL query being typed: the compiler underlines the misspelled 'emial', then 'titel' three levels deep inside $populate">
40
+ <img src="https://uql-orm.dev/demo-light.webp" alt="A UQL query being typed: the compiler underlines the misspelled 'emial', then 'titel' three levels deep inside $populate, then '$like' on a numeric column">
41
41
  </picture>
42
42
  </a>
43
43
 
@@ -1,4 +1,4 @@
1
- var W=[];function F(B){for(let V of W)V(B)}function _(B){W.push(B);let V=W.length-1;return()=>{W.splice(V,1)}}class f extends Error{status;constructor(B,V){super(B);this.status=V;this.name="RequestError"}}function N(B,V){return G(B,{method:"get"},V)}function Q(B,V,I){let X=JSON.stringify(V);return G(B,{method:"post",body:X},I)}function J(B,V,I){let X=JSON.stringify(V);return G(B,{method:"patch",body:X},I)}function L(B,V,I){let X=JSON.stringify(V);return G(B,{method:"put",body:X},I)}function x(B,V){return G(B,{method:"delete"},V)}function T(B,V,I){let X=JSON.stringify(V);return G(B,{method:"QUERY",body:X},I)}function G(B,V,I){if(F({phase:"start",opts:I}),V.headers={accept:"application/json","content-type":"application/json",...I?.headers},I?.signal)V.signal=I.signal;return fetch(B,V).then((X)=>X.json().then((Z)=>{if(X.status>=200&&X.status<300)return F({phase:"success",opts:I}),Z;let Y=Z,M={message:Y?.error?.message??X.statusText,code:Y?.error?.code??X.status};throw F({phase:"error",error:M,opts:I}),new f(M.message,M.code)})).finally(()=>{F({phase:"complete",opts:I})})}function z(B){return B?Object.keys(B):[]}function K(B){let V=B.charAt(0).toLowerCase();for(let I=1;I<B.length;++I)V+=B[I]===B[I].toUpperCase()?"-"+B[I].toLowerCase():B[I];return V}var $={findMany:{method:"GET",path:""},findOne:{method:"GET",path:"/one"},count:{method:"GET",path:"/count"},findOneById:{method:"GET",path:"/:id"},insertOne:{method:"POST",path:""},insertMany:{method:"POST",path:"/many"},saveOne:{method:"PUT",path:""},saveMany:{method:"PUT",path:"/many"},updateMany:{method:"PATCH",path:""},updateOneById:{method:"PATCH",path:"/:id"},deleteOneById:{method:"DELETE",path:"/:id"},deleteMany:{method:"DELETE",path:""}},k=z($),b=new Map(k.filter((B)=>$[B].method==="GET"&&$[B].path!=="/:id").map((B)=>[$[B].path,B]));function D(B){return K(B.name)}var c=["$select","$populate","$exclude","$where","$sort"],v=new Set([...c,"$skip","$limit","hardDelete","count"]);function A(B){if(!B)return"";let V=new URLSearchParams;for(let X of z(B)){let Z=B[X];if(Z===void 0)continue;V.append(X,typeof Z==="object"&&Z!==null?JSON.stringify(Z):String(Z))}let I=V.toString();return I?`?${I}`:""}class C{basePath;defaults;constructor(B,V={}){this.basePath=B;this.defaults=V}findOneById(B,V,I,X){let Z=this.getBasePath(B),H=A(I);return N(`${Z}/${V}${H}`,this.buildOptions(X))}findOne(B,V,I){return this.read(`${this.getBasePath(B)}${$.findOne.path}`,V,I)}findMany(B,V,I){let X={...V};if(I?.count)X.count=!0;return this.read(this.getBasePath(B),X,I)}async findManyAndCount(B,V,I){let X=await this.findMany(B,V,{...I,count:!0});if(typeof X.count!=="number")throw TypeError("findManyAndCount response has an invalid count");return{...X,count:X.count}}count(B,V,I){return this.read(`${this.getBasePath(B)}${$.count.path}`,V,I)}insertOne(B,V,I){let X=this.getBasePath(B);return Q(X,V,this.buildOptions(I))}insertMany(B,V,I){let X=this.getBasePath(B);return Q(`${X}${$.insertMany.path}`,V,this.buildOptions(I))}updateOneById(B,V,I,X){let Z=this.getBasePath(B);return J(`${Z}/${V}`,I,this.buildOptions(X))}updateMany(B,V,I,X){let Z=this.getBasePath(B),H=A(V);return J(`${Z}${H}`,I,this.buildOptions(X))}saveOne(B,V,I){let X=this.getBasePath(B);return L(X,V,this.buildOptions(I))}saveMany(B,V,I){let X=this.getBasePath(B);return L(`${X}${$.saveMany.path}`,V,this.buildOptions(I))}deleteOneById(B,V,I={}){let X=this.getBasePath(B),Z=I.hardDelete?A({hardDelete:I.hardDelete}):"";return x(`${X}/${V}${Z}`,this.buildOptions(I))}deleteMany(B,V,I={}){let X=this.getBasePath(B),Z=A(I.hardDelete?{...V,hardDelete:I.hardDelete}:V);return x(`${X}${Z}`,this.buildOptions(I))}getBasePath(B){return`${this.basePath}/${D(B)}`}read(B,V,I){if(this.defaults.readMethod==="QUERY")return T(B,V??{},this.buildOptions(I));return N(`${B}${A(V)}`,this.buildOptions(I))}buildOptions(B){if(!this.defaults.headers&&!B?.headers)return B;return{...B,headers:{...this.defaults.headers,...B?.headers}}}}var P={getQuerier:()=>new C("/api")};function y(B){P=B}function g(){return P}function p(){return g().getQuerier()}export{y as setQuerierPool,x as remove,T as query,L as put,Q as post,J as patch,_ as on,F as notify,g as getQuerierPool,p as getQuerier,N as get,f as RequestError,C as HttpQuerier};
1
+ var W=[];function F(B){for(let V of W)V(B)}function _(B){W.push(B);let V=W.length-1;return()=>{W.splice(V,1)}}class f extends Error{status;constructor(B,V){super(B);this.status=V;this.name="RequestError"}}function N(B,V){return G(B,{method:"get"},V)}function Q(B,V,I){let X=JSON.stringify(V);return G(B,{method:"post",body:X},I)}function J(B,V,I){let X=JSON.stringify(V);return G(B,{method:"patch",body:X},I)}function L(B,V,I){let X=JSON.stringify(V);return G(B,{method:"put",body:X},I)}function x(B,V){return G(B,{method:"delete"},V)}function T(B,V,I){let X=JSON.stringify(V);return G(B,{method:"QUERY",body:X},I)}function G(B,V,I){if(F({phase:"start",opts:I}),V.headers={accept:"application/json","content-type":"application/json",...I?.headers},I?.signal)V.signal=I.signal;return fetch(B,V).then((X)=>X.json().then((Z)=>{if(X.status>=200&&X.status<300)return F({phase:"success",opts:I}),Z;let Y=Z,M={message:Y?.error?.message??X.statusText,code:Y?.error?.code??X.status};throw F({phase:"error",error:M,opts:I}),new f(M.message,M.code)})).finally(()=>{F({phase:"complete",opts:I})})}function z(B){return B?Object.keys(B):[]}function K(B){let V=B.charAt(0).toLowerCase();for(let I=1;I<B.length;++I)V+=B[I]===B[I].toUpperCase()?"-"+B[I].toLowerCase():B[I];return V}var $={findMany:{method:"GET",path:""},findOne:{method:"GET",path:"/one"},count:{method:"GET",path:"/count"},findOneById:{method:"GET",path:"/:id"},insertOne:{method:"POST",path:""},insertMany:{method:"POST",path:"/many"},saveOne:{method:"PUT",path:""},saveMany:{method:"PUT",path:"/many"},updateMany:{method:"PATCH",path:""},updateOneById:{method:"PATCH",path:"/:id"},deleteOneById:{method:"DELETE",path:"/:id"},deleteMany:{method:"DELETE",path:""}},k=z($),R=new Map(k.filter((B)=>$[B].method==="GET"&&$[B].path!=="/:id").map((B)=>[$[B].path,B]));function D(B){return K(B.name)}var c=["$select","$populate","$exclude","$where","$sort"],v=new Set([...c,"$skip","$limit","hardDelete","count"]);function A(B){if(!B)return"";let V=new URLSearchParams;for(let X of z(B)){let Z=B[X];if(Z===void 0)continue;V.append(X,typeof Z==="object"&&Z!==null?JSON.stringify(Z):String(Z))}let I=V.toString();return I?`?${I}`:""}class C{basePath;defaults;constructor(B,V={}){this.basePath=B;this.defaults=V}findOneById(B,V,I,X){let Z=this.getBasePath(B),H=A(I);return N(`${Z}/${V}${H}`,this.buildOptions(X))}findOne(B,V,I){return this.read(`${this.getBasePath(B)}${$.findOne.path}`,V,I)}findMany(B,V,I){let X={...V};if(I?.count)X.count=!0;return this.read(this.getBasePath(B),X,I)}async findManyAndCount(B,V,I){let X=await this.findMany(B,V,{...I,count:!0});if(typeof X.count!=="number")throw TypeError("findManyAndCount response has an invalid count");return{...X,count:X.count}}count(B,V,I){return this.read(`${this.getBasePath(B)}${$.count.path}`,V,I)}insertOne(B,V,I){let X=this.getBasePath(B);return Q(X,V,this.buildOptions(I))}insertMany(B,V,I){let X=this.getBasePath(B);return Q(`${X}${$.insertMany.path}`,V,this.buildOptions(I))}updateOneById(B,V,I,X){let Z=this.getBasePath(B);return J(`${Z}/${V}`,I,this.buildOptions(X))}updateMany(B,V,I,X){let Z=this.getBasePath(B),H=A(V);return J(`${Z}${H}`,I,this.buildOptions(X))}saveOne(B,V,I){let X=this.getBasePath(B);return L(X,V,this.buildOptions(I))}saveMany(B,V,I){let X=this.getBasePath(B);return L(`${X}${$.saveMany.path}`,V,this.buildOptions(I))}deleteOneById(B,V,I={}){let X=this.getBasePath(B),Z=I.hardDelete?A({hardDelete:I.hardDelete}):"";return x(`${X}/${V}${Z}`,this.buildOptions(I))}deleteMany(B,V,I={}){let X=this.getBasePath(B),Z=A(I.hardDelete?{...V,hardDelete:I.hardDelete}:V);return x(`${X}${Z}`,this.buildOptions(I))}getBasePath(B){return`${this.basePath}/${D(B)}`}read(B,V,I){if(this.defaults.readMethod==="QUERY")return T(B,V??{},this.buildOptions(I));return N(`${B}${A(V)}`,this.buildOptions(I))}buildOptions(B){if(!this.defaults.headers&&!B?.headers)return B;return{...B,headers:{...this.defaults.headers,...B?.headers}}}}var P={getQuerier:()=>new C("/api")};function y(B){P=B}function g(){return P}function p(){return g().getQuerier()}export{y as setQuerierPool,x as remove,T as query,L as put,Q as post,J as patch,_ as on,F as notify,g as getQuerierPool,p as getQuerier,N as get,f as RequestError,C as HttpQuerier};
2
2
 
3
- //# debugId=63F5785EAD0E521C64756E2164756E21
3
+ //# debugId=BB4E96BEA0CE335F64756E2164756E21
4
4
  //# sourceMappingURL=uql-browser.min.js.map
@@ -4,14 +4,14 @@
4
4
  "sourcesContent": [
5
5
  "import type { RequestCallback, RequestNotification } from '../type/index.js';\n\nconst subscriptors: RequestCallback[] = [];\n\nexport function notify(notification: RequestNotification): void {\n for (const subscriptor of subscriptors) {\n subscriptor(notification);\n }\n}\n\nexport function on(cb: RequestCallback): () => void {\n subscriptors.push(cb);\n const index = subscriptors.length - 1;\n return (): void => {\n subscriptors.splice(index, 1);\n };\n}\n",
6
6
  "import type { RequestErrorResponse, RequestSuccessResponse } from '../../http/contract.js';\nimport type { RequestOptions } from '../type/index.js';\nimport { notify } from './bus.js';\n\n/**\n * Error thrown for non-2xx responses. Carries the HTTP status so callers can key\n * behavior on it (401 redirects, 402 payment flows, error-boundary routing).\n */\nexport class RequestError extends Error {\n constructor(\n message: string,\n readonly status: number,\n ) {\n super(message);\n this.name = 'RequestError';\n }\n}\n\nexport function get<T>(url: string, opts?: RequestOptions) {\n return request<T>(url, { method: 'get' }, opts);\n}\n\nexport function post<T>(url: string, payload: unknown, opts?: RequestOptions) {\n const body = JSON.stringify(payload);\n return request<T>(url, { method: 'post', body }, opts);\n}\n\nexport function patch<T>(url: string, payload: unknown, opts?: RequestOptions) {\n const body = JSON.stringify(payload);\n return request<T>(url, { method: 'patch', body }, opts);\n}\n\nexport function put<T>(url: string, payload: unknown, opts?: RequestOptions) {\n const body = JSON.stringify(payload);\n return request<T>(url, { method: 'put', body }, opts);\n}\n\nexport function remove<T>(url: string, opts?: RequestOptions) {\n return request<T>(url, { method: 'delete' }, opts);\n}\n\n/**\n * HTTP QUERY (RFC 10008): a safe, idempotent read whose JSON query travels in the\n * request body, avoiding URL-length limits. Method name must stay uppercase\n * (fetch only normalizes the classic verbs).\n */\nexport function query<T>(url: string, payload: unknown, opts?: RequestOptions) {\n const body = JSON.stringify(payload);\n return request<T>(url, { method: 'QUERY', body }, opts);\n}\n\nfunction request<T>(url: string, init: RequestInit, opts?: RequestOptions) {\n notify({ phase: 'start', opts });\n\n init.headers = {\n accept: 'application/json',\n 'content-type': 'application/json',\n ...opts?.headers,\n };\n if (opts?.signal) {\n init.signal = opts.signal;\n }\n\n return fetch(url, init)\n .then((rawResp) =>\n rawResp.json().then((resp: unknown) => {\n const isSuccess = rawResp.status >= 200 && rawResp.status < 300;\n if (isSuccess) {\n notify({ phase: 'success', opts });\n return resp as RequestSuccessResponse<T>;\n }\n const errorResp = resp as Partial<RequestErrorResponse> | undefined;\n const error = {\n message: errorResp?.error?.message ?? rawResp.statusText,\n code: errorResp?.error?.code ?? rawResp.status,\n };\n notify({ phase: 'error', error, opts });\n throw new RequestError(error.message, error.code);\n }),\n )\n .finally(() => {\n notify({ phase: 'complete', opts });\n });\n}\n",
7
- "import type { FieldKey, FieldOptions } from '../type/index.js';\n\nexport function throwPendingTransaction(): never {\n throw TypeError('pending transaction');\n}\n\nexport function throwNoPendingTransaction(): never {\n throw TypeError('not a pending transaction');\n}\n\nexport function clone<T>(value: T): T {\n if (typeof value !== 'object' || value === null) {\n return value;\n }\n if (Array.isArray(value)) {\n return value.map((it) => clone(it)) as T;\n }\n return { ...value };\n}\n\n/** Whether `obj` has at least one enumerable key. Narrows away `undefined`/`null` for callers. */\nexport function hasKeys<T>(obj: T): obj is NonNullable<T> {\n if (typeof obj !== 'object' || obj === null) return false;\n for (const _ in obj) return true;\n return false;\n}\n\n/** Whether `obj` has at least two enumerable keys. */\nexport function hasMultipleKeys(obj: object): boolean {\n let count = 0;\n for (const _ in obj) {\n if (++count > 1) return true;\n }\n return false;\n}\n\n/**\n * Whether any enumerable key of `obj` satisfies `pred`, short-circuiting on the first match\n * without materializing a key array (unlike `Object.keys(obj).some(pred)`).\n */\nexport function someKey(obj: object, pred: (key: string) => boolean): boolean {\n for (const key in obj) {\n if (pred(key)) return true;\n }\n return false;\n}\n\n/** Whether any enumerable value of `obj` satisfies `pred`, short-circuiting like {@link someKey}. */\nexport function someValue(obj: object, pred: (value: unknown) => boolean): boolean {\n return someKey(obj, (key) => pred((obj as Record<string, unknown>)[key]));\n}\n\nconst isOperatorKey = (key: string) => key.startsWith('$');\n\n/**\n * Whether `value` is a non-empty object whose keys are query/update operators (`$eq`, `$push`, ...).\n * The single source of this test: the SQL dialects, the MongoDB dialect and the `$elemMatch` walker\n * all classify operator objects with it, and they used to disagree about `{}`.\n */\nexport function isOperatorObject(value: unknown): value is Record<string, unknown> {\n return hasKeys(value) && !Array.isArray(value) && someKey(value, isOperatorKey);\n}\n\n/** Whether every key of the non-empty object `value` is an operator (no plain field names mixed in). */\nexport function isOperatorOnlyObject(value: unknown): value is Record<string, unknown> {\n return hasKeys(value) && !Array.isArray(value) && !someKey(value, (key) => !isOperatorKey(key));\n}\n\nexport function getKeys<T extends object>(obj: T): (keyof T & string)[] {\n return obj ? (Object.keys(obj) as (keyof T & string)[]) : [];\n}\n\nexport function getFieldKeys<E>(\n fields: {\n [K in FieldKey<E>]?: FieldOptions;\n },\n): FieldKey<E>[] {\n return getKeys(fields).filter((field) => fields[field]!.eager ?? true);\n}\n",
7
+ "import type { FieldKey, FieldOptions } from '../type/index.js';\n\nexport function throwPendingTransaction(): never {\n throw TypeError('pending transaction');\n}\n\nexport function throwNoPendingTransaction(): never {\n throw TypeError('not a pending transaction');\n}\n\nexport function clone<T>(value: T): T {\n if (typeof value !== 'object' || value === null) {\n return value;\n }\n if (Array.isArray(value)) {\n return value.map((it) => clone(it)) as T;\n }\n return { ...value };\n}\n\n/** Whether `obj` has at least one enumerable key. Narrows away `undefined`/`null` for callers. */\nexport function hasKeys<T>(obj: T): obj is NonNullable<T> {\n if (typeof obj !== 'object' || obj === null) return false;\n for (const _ in obj) return true;\n return false;\n}\n\n/**\n * Whether any enumerable key of `obj` satisfies `pred`, short-circuiting on the first match\n * without materializing a key array (unlike `Object.keys(obj).some(pred)`).\n */\nexport function someKey<T extends object>(obj: T, pred: (key: keyof T & string) => boolean): boolean {\n for (const key in obj) {\n if (pred(key)) return true;\n }\n return false;\n}\n\n/** Whether any enumerable value of `obj` satisfies `pred`, short-circuiting like {@link someKey}. */\nexport function someValue(obj: object, pred: (value: unknown) => boolean): boolean {\n return someKey(obj, (key) => pred((obj as Record<string, unknown>)[key]));\n}\n\nconst isOperatorKey = (key: string) => key.startsWith('$');\n\n/**\n * Whether `value` is a non-empty object whose keys are query/update operators (`$eq`, `$push`, ...).\n * The single source of this test: the SQL dialects, the MongoDB dialect and the `$elemMatch` walker\n * all classify operator objects with it, and they used to disagree about `{}`.\n */\nexport function isOperatorObject(value: unknown): value is Record<string, unknown> {\n return hasKeys(value) && !Array.isArray(value) && someKey(value, isOperatorKey);\n}\n\n/** Whether every key of the non-empty object `value` is an operator (no plain field names mixed in). */\nexport function isOperatorOnlyObject(value: unknown): value is Record<string, unknown> {\n return hasKeys(value) && !Array.isArray(value) && !someKey(value, (key) => !isOperatorKey(key));\n}\n\nexport function getKeys<T extends object>(obj: T): (keyof T & string)[] {\n return obj ? (Object.keys(obj) as (keyof T & string)[]) : [];\n}\n\nexport function getFieldKeys<E>(\n fields: {\n [K in FieldKey<E>]?: FieldOptions;\n },\n): FieldKey<E>[] {\n return getKeys(fields).filter((field) => fields[field]!.eager ?? true);\n}\n",
8
8
  "export function kebabCase(val: string): string {\n let resp = val.charAt(0).toLowerCase();\n for (let i = 1; i < val.length; ++i) {\n resp += val[i] === val[i].toUpperCase() ? '-' + val[i].toLowerCase() : val[i];\n }\n return resp;\n}\n\nexport function upperFirst(text: string): string {\n if (!text) return text;\n return text[0].toUpperCase() + text.slice(1);\n}\n\nexport function lowerFirst(text: string): string {\n if (!text) return text;\n return text[0].toLowerCase() + text.slice(1);\n}\n\nexport function snakeCase(val: string): string {\n if (val === null || val === undefined) return val as string;\n if (!val) return '';\n let resp = val.charAt(0).toLowerCase();\n for (let i = 1; i < val.length; ++i) {\n const char = val[i];\n const charLower = char.toLowerCase();\n if (char !== charLower && char === char.toUpperCase()) {\n resp += '_' + charLower;\n } else {\n resp += char;\n }\n }\n return resp;\n}\n\n/**\n * Convert a string to PascalCase (UpperCamelCase).\n * @example 'user_profile' -> 'UserProfile'\n * @example 'some-text' -> 'SomeText'\n */\nexport function pascalCase(str: string): string {\n if (!str) return '';\n return str\n .split(/[_\\s-]+/)\n .map((word) => {\n // Lower-casing the rest is only right for a word that carries no case of its own: it turns\n // `USER_ID` into `UserId`, but it also turns `tenantId` into `Tenantid`.\n const rest = word === word.toUpperCase() ? word.slice(1).toLowerCase() : word.slice(1);\n return word.charAt(0).toUpperCase() + rest;\n })\n .join('');\n}\n\n/**\n * Convert a string to camelCase.\n * @example 'user_profile' -> 'userProfile'\n * @example 'SomeText' -> 'someText'\n */\nexport function camelCase(str: string): string {\n const pascal = pascalCase(str);\n return pascal.charAt(0).toLowerCase() + pascal.slice(1);\n}\n\n/**\n * Simple singularize function for English words.\n * @example 'users' -> 'user'\n * @example 'categories' -> 'category'\n */\nexport function singularize(name: string): string {\n if (!name) return '';\n if (name.endsWith('ies')) {\n return name.slice(0, -3) + 'y';\n }\n if (name.endsWith('ses') || name.endsWith('xes') || name.endsWith('zes')) {\n return name.slice(0, -2);\n }\n if (name.endsWith('s') && !name.endsWith('ss')) {\n return name.slice(0, -1);\n }\n return name;\n}\n\n/**\n * Simple pluralize function for English words.\n * @example 'user' -> 'users'\n * @example 'category' -> 'categories'\n */\nexport function pluralize(name: string): string {\n if (!name) return '';\n if (name.endsWith('y') && name.length > 1 && !/[aeiou]/.test(name[name.length - 2])) {\n return name.slice(0, -1) + 'ies';\n }\n if (name.endsWith('s') || name.endsWith('x') || name.endsWith('z') || name.endsWith('ch') || name.endsWith('sh')) {\n return name + 'es';\n }\n return name + 's';\n}\n",
9
9
  "import type { Type, UniversalQuerier } from '../type/index.js';\n// the specific util modules, not the barrel, so the browser bundle does not pull in entity metadata\nimport { getKeys } from '../util/object.util.js';\nimport { kebabCase } from '../util/string.util.js';\n\ntype RouteShape = {\n readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n readonly path: '' | `/${string}`;\n};\n\n/**\n * Single source of truth for the CRUD-over-HTTP surface, shared by server adapters and the browser client.\n * Keys are constrained to {@link UniversalQuerier} method names, so renaming a querier method\n * (or routing a non-existent one) is a compile error.\n */\nexport const CRUD_ROUTES = {\n findMany: { method: 'GET', path: '' },\n findOne: { method: 'GET', path: '/one' },\n count: { method: 'GET', path: '/count' },\n findOneById: { method: 'GET', path: '/:id' },\n insertOne: { method: 'POST', path: '' },\n insertMany: { method: 'POST', path: '/many' },\n saveOne: { method: 'PUT', path: '' },\n saveMany: { method: 'PUT', path: '/many' },\n updateMany: { method: 'PATCH', path: '' },\n updateOneById: { method: 'PATCH', path: '/:id' },\n deleteOneById: { method: 'DELETE', path: '/:id' },\n deleteMany: { method: 'DELETE', path: '' },\n} as const satisfies Partial<Record<keyof UniversalQuerier, RouteShape>>;\n\nexport type CrudOperation = keyof typeof CRUD_ROUTES;\n\nexport type CrudRoute = (typeof CRUD_ROUTES)[CrudOperation];\n\n/**\n * `QUERY` (RFC 10008) is an alternate transport for the read operations: same semantics as the\n * GET routes, but the JSON query travels in the request body instead of the query string,\n * avoiding URL-length limits for large queries.\n */\nexport type HttpMethod = CrudRoute['method'] | 'QUERY';\n\nconst CRUD_OPS = getKeys(CRUD_ROUTES);\n\n// derived from CRUD_ROUTES (the literal-path GET routes) so the sub-paths live in exactly one place\nconst QUERY_READ_OPS: ReadonlyMap<string, CrudOperation> = new Map(\n CRUD_OPS.filter((op) => CRUD_ROUTES[op].method === 'GET' && CRUD_ROUTES[op].path !== '/:id').map((op) => [\n CRUD_ROUTES[op].path,\n op,\n ]),\n);\n\n/**\n * URL segment for an entity, e.g. `entityPath(UserProfile) === 'user-profile'`.\n */\nexport function entityPath<E>(entity: Type<E>): string {\n return kebabCase(entity.name);\n}\n\nexport type RouteMatch = {\n readonly op: CrudOperation;\n /**\n * the resolved transport method - differs from the op's canonical route method for QUERY.\n */\n readonly method: HttpMethod;\n readonly id?: string;\n};\n\n/**\n * Resolve a (method, sub-path) pair to a CRUD operation. Literal sub-paths win over `:id`.\n */\nexport function matchRoute(method: string, subPath: string | undefined): RouteMatch | undefined {\n const raw = method.toUpperCase();\n const literal = subPath === undefined ? '' : `/${subPath}`;\n if (raw === 'QUERY') {\n const op = QUERY_READ_OPS.get(literal);\n return op ? { op, method: 'QUERY' } : undefined;\n }\n // HEAD reads like GET per HTTP semantics; the server runtime omits the response body\n const verb = raw === 'HEAD' ? 'GET' : raw;\n let idOp: CrudOperation | undefined;\n for (const op of CRUD_OPS) {\n const route = CRUD_ROUTES[op];\n if (route.method !== verb) {\n continue;\n }\n if (route.path === literal) {\n return { op, method: route.method };\n }\n if (route.path === '/:id') {\n idOp = op;\n }\n }\n return idOp && subPath !== undefined ? { op: idOp, method: CRUD_ROUTES[idOp].method, id: subPath } : undefined;\n}\n\nexport type RequestSuccessResponse<E> = {\n data: E;\n count?: number;\n};\n\nexport type RequestCountedSuccessResponse<E> = RequestSuccessResponse<E> & {\n count: number;\n};\n\nexport type RequestErrorResponse = {\n readonly error: {\n readonly message: string;\n readonly code: number;\n };\n};\n\n/**\n * Map a thrown error to the wire error envelope. Honors a numeric `status` on the error\n * (e.g. hooks throwing 403), defaults to 500; `code` mirrors the HTTP status.\n */\nexport function toErrorResponse(err: unknown): { status: number; body: RequestErrorResponse } {\n const status = err instanceof Error && 'status' in err && typeof err.status === 'number' ? err.status : 500;\n const message = err instanceof Error ? err.message : 'Internal Server Error';\n return { status, body: { error: { message, code: status } } };\n}\n",
10
10
  "import type { Query, QueryOptions } from '../type/index.js';\n// the specific util module, not the barrel, so the browser bundle does not pull in entity metadata\nimport { getKeys } from '../util/object.util.js';\n\nconst JSON_QUERY_KEYS = [\n '$select',\n '$populate',\n '$exclude',\n '$where',\n '$sort',\n] as const satisfies readonly (keyof Query<unknown>)[];\n\n/**\n * Keys accepted from the wire - query structure ({@link Query}) plus the `hardDelete`/`count` scalar\n * flags. Anything else (e.g. `filters`, `context`, `$entity`) is dropped so a remote client can't\n * bypass a security filter or inject ambient context - those are server-only. The `satisfies` ties\n * every entry to a real query/option key, so a typo or a renamed option fails to compile.\n */\nconst ALLOWED_QUERY_KEYS = new Set<string>([...JSON_QUERY_KEYS, '$skip', '$limit', 'hardDelete', 'count'] satisfies (\n | keyof Query<unknown>\n | keyof Pick<QueryOptions, 'hardDelete'>\n | 'count'\n)[]);\n\n/**\n * Keys that mean something locally but that this transport can never honor, so they are rejected\n * rather than dropped like the rest. Each request runs on its own auto-committing connection, so a\n * row lock taken here is released before the response is written: honoring `$lock` is impossible,\n * and ignoring it would hand the caller a read they believe is serialized and is not.\n */\nconst REJECTED_QUERY_KEYS = new Set<string>(['$lock'] satisfies (keyof Query<unknown>)[]);\n\n/**\n * Parse raw query-string entries (with JSON-stringified values) into a UQL query object.\n * Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.\n */\nexport function parseQueryParams(params: Record<string, unknown> = {}): Query<unknown> {\n const query: Record<string, unknown> = {};\n for (const key of getKeys(params)) {\n if (REJECTED_QUERY_KEYS.has(key)) {\n throw Object.assign(new TypeError(`'${key}' is not supported over HTTP`), { status: 400 });\n }\n if (ALLOWED_QUERY_KEYS.has(key)) {\n query[key] = params[key];\n }\n }\n\n for (const key of JSON_QUERY_KEYS) {\n const value = query[key];\n if (typeof value === 'string') {\n try {\n query[key] = JSON.parse(value);\n } catch {\n throw Object.assign(new SyntaxError(`invalid JSON in '${key}'`), { status: 400 });\n }\n }\n }\n\n query['$where'] ??= {};\n\n if (query['$skip']) {\n query['$skip'] = Number(query['$skip']);\n }\n if (query['$limit']) {\n query['$limit'] = Number(query['$limit']);\n }\n\n return query as Query<unknown>;\n}\n\n/**\n * Serialize a UQL query object into a percent-encoded query string where object values\n * are JSON-stringified. Symmetric counterpart of {@link parseQueryParams}.\n */\nexport function stringifyQuery(query?: Record<string, unknown>): string {\n if (!query) {\n return '';\n }\n const params = new URLSearchParams();\n for (const key of getKeys(query)) {\n const value = query[key];\n if (value === undefined) {\n continue;\n }\n params.append(key, typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value));\n }\n const qs = params.toString();\n return qs ? `?${qs}` : '';\n}\n",
11
11
  "import {\n CRUD_ROUTES,\n entityPath,\n type HttpMethod,\n type RequestCountedSuccessResponse,\n type RequestSuccessResponse,\n} from '../../http/contract.js';\nimport { stringifyQuery } from '../../http/query.js';\nimport type {\n EntityData,\n IdValue,\n Query,\n QueryOne,\n QueryOptions,\n QuerySearch,\n Type,\n UpdatePayload,\n} from '../../type/index.js';\nimport { get, query as httpQuery, patch, post, put, remove } from '../http/index.js';\nimport type { ClientQuerier, RequestFindOptions, RequestOptions } from '../type/index.js';\n\nexport type HttpQuerierDefaults = {\n /**\n * headers sent with every request from this instance, merged under per-call headers.\n * Create one instance per request (e.g. during SSR) to scope auth headers safely.\n */\n readonly headers?: Record<string, string>;\n /**\n * transport for read queries (findOne, findMany, count). 'QUERY' (RFC 10008) sends the\n * JSON query in the request body, avoiding URL-length limits for large queries; requires\n * infrastructure (proxies, CDNs) that forwards the QUERY method. Defaults to 'GET'.\n */\n readonly readMethod?: Extract<HttpMethod, 'GET' | 'QUERY'>;\n};\n\nexport class HttpQuerier implements ClientQuerier {\n constructor(\n readonly basePath: string,\n readonly defaults: HttpQuerierDefaults = {},\n ) {}\n\n findOneById<E extends object>(\n entity: Type<E>,\n id: IdValue<E>,\n q?: QueryOne<E>,\n opts?: RequestOptions,\n ): Promise<RequestSuccessResponse<E | undefined>> {\n const basePath = this.getBasePath(entity);\n const qs = stringifyQuery(q);\n return get<E | undefined>(`${basePath}/${id}${qs}`, this.buildOptions(opts));\n }\n\n findOne<E extends object>(\n entity: Type<E>,\n q: QueryOne<E>,\n opts?: RequestOptions,\n ): Promise<RequestSuccessResponse<E | undefined>> {\n return this.read<E | undefined>(`${this.getBasePath(entity)}${CRUD_ROUTES.findOne.path}`, q, opts);\n }\n\n findMany<E extends object>(\n entity: Type<E>,\n q: Query<E>,\n opts?: RequestFindOptions,\n ): Promise<RequestSuccessResponse<E[]>> {\n const data: Query<E> & { count?: boolean } = { ...q };\n if (opts?.count) {\n data.count = true;\n }\n return this.read<E[]>(this.getBasePath(entity), data, opts);\n }\n\n async findManyAndCount<E extends object>(\n entity: Type<E>,\n q: Query<E>,\n opts?: RequestFindOptions,\n ): Promise<RequestCountedSuccessResponse<E[]>> {\n const response = await this.findMany(entity, q, { ...opts, count: true });\n if (typeof response.count !== 'number') {\n throw new TypeError('findManyAndCount response has an invalid count');\n }\n return { ...response, count: response.count };\n }\n\n count<E extends object>(entity: Type<E>, q?: QuerySearch<E>, opts?: RequestOptions) {\n return this.read<number>(`${this.getBasePath(entity)}${CRUD_ROUTES.count.path}`, q, opts);\n }\n\n insertOne<E extends object>(entity: Type<E>, payload: EntityData<E>, opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return post<IdValue<E> | undefined>(basePath, payload, this.buildOptions(opts));\n }\n\n insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[], opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return post<IdValue<E>[]>(`${basePath}${CRUD_ROUTES.insertMany.path}`, payload, this.buildOptions(opts));\n }\n\n updateOneById<E extends object>(entity: Type<E>, id: IdValue<E>, payload: UpdatePayload<E>, opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return patch<number>(`${basePath}/${id}`, payload, this.buildOptions(opts));\n }\n\n updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n const qs = stringifyQuery(q);\n return patch<number>(`${basePath}${qs}`, payload, this.buildOptions(opts));\n }\n\n saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>, opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return put<IdValue<E>>(basePath, payload, this.buildOptions(opts));\n }\n\n saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[], opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return put<IdValue<E>[]>(`${basePath}${CRUD_ROUTES.saveMany.path}`, payload, this.buildOptions(opts));\n }\n\n deleteOneById<E extends object>(entity: Type<E>, id: IdValue<E>, opts: QueryOptions & RequestOptions = {}) {\n const basePath = this.getBasePath(entity);\n const qs = opts.hardDelete ? stringifyQuery({ hardDelete: opts.hardDelete }) : '';\n return remove<number>(`${basePath}/${id}${qs}`, this.buildOptions(opts));\n }\n\n deleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts: QueryOptions & RequestOptions = {}) {\n const basePath = this.getBasePath(entity);\n const qs = stringifyQuery(opts.hardDelete ? { ...q, hardDelete: opts.hardDelete } : q);\n return remove<number>(`${basePath}${qs}`, this.buildOptions(opts));\n }\n\n getBasePath<E>(entity: Type<E>) {\n return `${this.basePath}/${entityPath(entity)}`;\n }\n\n protected read<T>(path: string, q: Record<string, unknown> | undefined, opts?: RequestOptions) {\n if (this.defaults.readMethod === 'QUERY') {\n return httpQuery<T>(path, q ?? {}, this.buildOptions(opts));\n }\n return get<T>(`${path}${stringifyQuery(q)}`, this.buildOptions(opts));\n }\n\n protected buildOptions(opts?: RequestOptions): RequestOptions | undefined {\n if (!this.defaults.headers && !opts?.headers) {\n return opts;\n }\n return { ...opts, headers: { ...this.defaults.headers, ...opts?.headers } };\n }\n}\n",
12
12
  "import { HttpQuerier } from './querier/httpQuerier.js';\nimport type { ClientQuerier, ClientQuerierPool } from './type/index.js';\n\nlet defaultPool: ClientQuerierPool = {\n getQuerier: () => new HttpQuerier('/api'),\n};\n\nexport function setQuerierPool<T extends ClientQuerierPool>(pool: T) {\n defaultPool = pool;\n}\n\nexport function getQuerierPool(): ClientQuerierPool {\n return defaultPool;\n}\n\nexport function getQuerier(): ClientQuerier {\n return getQuerierPool().getQuerier();\n}\n"
13
13
  ],
14
- "mappings": "AAEA,IAAM,EAAkC,CAAC,EAElC,SAAS,CAAM,CAAC,EAAyC,CAC9D,QAAW,KAAe,EACxB,EAAY,CAAY,EAIrB,SAAS,CAAE,CAAC,EAAiC,CAClD,EAAa,KAAK,CAAE,EACpB,IAAM,EAAQ,EAAa,OAAS,EACpC,MAAO,IAAY,CACjB,EAAa,OAAO,EAAO,CAAC,GCNzB,MAAM,UAAqB,KAAM,CAG3B,OAFX,WAAW,CACT,EACS,EACT,CACA,MAAM,CAAO,EAFJ,cAGT,KAAK,KAAO,eAEhB,CAEO,SAAS,CAAM,CAAC,EAAa,EAAuB,CACzD,OAAO,EAAW,EAAK,CAAE,OAAQ,KAAM,EAAG,CAAI,EAGzC,SAAS,CAAO,CAAC,EAAa,EAAkB,EAAuB,CAC5E,IAAM,EAAO,KAAK,UAAU,CAAO,EACnC,OAAO,EAAW,EAAK,CAAE,OAAQ,OAAQ,MAAK,EAAG,CAAI,EAGhD,SAAS,CAAQ,CAAC,EAAa,EAAkB,EAAuB,CAC7E,IAAM,EAAO,KAAK,UAAU,CAAO,EACnC,OAAO,EAAW,EAAK,CAAE,OAAQ,QAAS,MAAK,EAAG,CAAI,EAGjD,SAAS,CAAM,CAAC,EAAa,EAAkB,EAAuB,CAC3E,IAAM,EAAO,KAAK,UAAU,CAAO,EACnC,OAAO,EAAW,EAAK,CAAE,OAAQ,MAAO,MAAK,EAAG,CAAI,EAG/C,SAAS,CAAS,CAAC,EAAa,EAAuB,CAC5D,OAAO,EAAW,EAAK,CAAE,OAAQ,QAAS,EAAG,CAAI,EAQ5C,SAAS,CAAQ,CAAC,EAAa,EAAkB,EAAuB,CAC7E,IAAM,EAAO,KAAK,UAAU,CAAO,EACnC,OAAO,EAAW,EAAK,CAAE,OAAQ,QAAS,MAAK,EAAG,CAAI,EAGxD,SAAS,CAAU,CAAC,EAAa,EAAmB,EAAuB,CAQzE,GAPA,EAAO,CAAE,MAAO,QAAS,MAAK,CAAC,EAE/B,EAAK,QAAU,CACb,OAAQ,mBACR,eAAgB,sBACb,GAAM,OACX,EACI,GAAM,OACR,EAAK,OAAS,EAAK,OAGrB,OAAO,MAAM,EAAK,CAAI,EACnB,KAAK,CAAC,IACL,EAAQ,KAAK,EAAE,KAAK,CAAC,IAAkB,CAErC,GADkB,EAAQ,QAAU,KAAO,EAAQ,OAAS,IAG1D,OADA,EAAO,CAAE,MAAO,UAAW,MAAK,CAAC,EAC1B,EAET,IAAM,EAAY,EACZ,EAAQ,CACZ,QAAS,GAAW,OAAO,SAAW,EAAQ,WAC9C,KAAM,GAAW,OAAO,MAAQ,EAAQ,MAC1C,EAEA,MADA,EAAO,CAAE,MAAO,QAAS,QAAO,MAAK,CAAC,EAChC,IAAI,EAAa,EAAM,QAAS,EAAM,IAAI,EACjD,CACH,EACC,QAAQ,IAAM,CACb,EAAO,CAAE,MAAO,WAAY,MAAK,CAAC,EACnC,ECdE,SAAS,CAAyB,CAAC,EAA8B,CACtE,OAAO,EAAO,OAAO,KAAK,CAAG,EAA6B,CAAC,ECrEtD,SAAS,CAAS,CAAC,EAAqB,CAC7C,IAAI,EAAO,EAAI,OAAO,CAAC,EAAE,YAAY,EACrC,QAAS,EAAI,EAAG,EAAI,EAAI,OAAQ,EAAE,EAChC,GAAQ,EAAI,KAAO,EAAI,GAAG,YAAY,EAAI,IAAM,EAAI,GAAG,YAAY,EAAI,EAAI,GAE7E,OAAO,ECUF,IAAM,EAAc,CACzB,SAAU,CAAE,OAAQ,MAAO,KAAM,EAAG,EACpC,QAAS,CAAE,OAAQ,MAAO,KAAM,MAAO,EACvC,MAAO,CAAE,OAAQ,MAAO,KAAM,QAAS,EACvC,YAAa,CAAE,OAAQ,MAAO,KAAM,MAAO,EAC3C,UAAW,CAAE,OAAQ,OAAQ,KAAM,EAAG,EACtC,WAAY,CAAE,OAAQ,OAAQ,KAAM,OAAQ,EAC5C,QAAS,CAAE,OAAQ,MAAO,KAAM,EAAG,EACnC,SAAU,CAAE,OAAQ,MAAO,KAAM,OAAQ,EACzC,WAAY,CAAE,OAAQ,QAAS,KAAM,EAAG,EACxC,cAAe,CAAE,OAAQ,QAAS,KAAM,MAAO,EAC/C,cAAe,CAAE,OAAQ,SAAU,KAAM,MAAO,EAChD,WAAY,CAAE,OAAQ,SAAU,KAAM,EAAG,CAC3C,EAaM,EAAW,EAAQ,CAAW,EAG9B,EAAqD,IAAI,IAC7D,EAAS,OAAO,CAAC,IAAO,EAAY,GAAI,SAAW,OAAS,EAAY,GAAI,OAAS,MAAM,EAAE,IAAI,CAAC,IAAO,CACvG,EAAY,GAAI,KAChB,CACF,CAAC,CACH,EAKO,SAAS,CAAa,CAAC,EAAyB,CACrD,OAAO,EAAU,EAAO,IAAI,ECnD9B,IAAM,EAAkB,CACtB,UACA,YACA,WACA,SACA,OACF,EAQM,EAAqB,IAAI,IAAY,CAAC,GAAG,EAAiB,QAAS,SAAU,aAAc,OAAO,CAIrG,EAoDI,SAAS,CAAc,CAAC,EAAyC,CACtE,GAAI,CAAC,EACH,MAAO,GAET,IAAM,EAAS,IAAI,gBACnB,QAAW,KAAO,EAAQ,CAAK,EAAG,CAChC,IAAM,EAAQ,EAAM,GACpB,GAAI,IAAU,OACZ,SAEF,EAAO,OAAO,EAAK,OAAO,IAAU,UAAY,IAAU,KAAO,KAAK,UAAU,CAAK,EAAI,OAAO,CAAK,CAAC,EAExG,IAAM,EAAK,EAAO,SAAS,EAC3B,OAAO,EAAK,IAAI,IAAO,GCpDlB,MAAM,CAAqC,CAErC,SACA,SAFX,WAAW,CACA,EACA,EAAgC,CAAC,EAC1C,CAFS,gBACA,gBAGX,WAA6B,CAC3B,EACA,EACA,EACA,EACgD,CAChD,IAAM,EAAW,KAAK,YAAY,CAAM,EAClC,EAAK,EAAe,CAAC,EAC3B,OAAO,EAAmB,GAAG,KAAY,IAAK,IAAM,KAAK,aAAa,CAAI,CAAC,EAG7E,OAAyB,CACvB,EACA,EACA,EACgD,CAChD,OAAO,KAAK,KAAoB,GAAG,KAAK,YAAY,CAAM,IAAI,EAAY,QAAQ,OAAQ,EAAG,CAAI,EAGnG,QAA0B,CACxB,EACA,EACA,EACsC,CACtC,IAAM,EAAuC,IAAK,CAAE,EACpD,GAAI,GAAM,MACR,EAAK,MAAQ,GAEf,OAAO,KAAK,KAAU,KAAK,YAAY,CAAM,EAAG,EAAM,CAAI,OAGtD,iBAAkC,CACtC,EACA,EACA,EAC6C,CAC7C,IAAM,EAAW,MAAM,KAAK,SAAS,EAAQ,EAAG,IAAK,EAAM,MAAO,EAAK,CAAC,EACxE,GAAI,OAAO,EAAS,QAAU,SAC5B,MAAU,UAAU,gDAAgD,EAEtE,MAAO,IAAK,EAAU,MAAO,EAAS,KAAM,EAG9C,KAAuB,CAAC,EAAiB,EAAoB,EAAuB,CAClF,OAAO,KAAK,KAAa,GAAG,KAAK,YAAY,CAAM,IAAI,EAAY,MAAM,OAAQ,EAAG,CAAI,EAG1F,SAA2B,CAAC,EAAiB,EAAwB,EAAuB,CAC1F,IAAM,EAAW,KAAK,YAAY,CAAM,EACxC,OAAO,EAA6B,EAAU,EAAS,KAAK,aAAa,CAAI,CAAC,EAGhF,UAA4B,CAAC,EAAiB,EAA0B,EAAuB,CAC7F,IAAM,EAAW,KAAK,YAAY,CAAM,EACxC,OAAO,EAAmB,GAAG,IAAW,EAAY,WAAW,OAAQ,EAAS,KAAK,aAAa,CAAI,CAAC,EAGzG,aAA+B,CAAC,EAAiB,EAAgB,EAA2B,EAAuB,CACjH,IAAM,EAAW,KAAK,YAAY,CAAM,EACxC,OAAO,EAAc,GAAG,KAAY,IAAM,EAAS,KAAK,aAAa,CAAI,CAAC,EAG5E,UAA4B,CAAC,EAAiB,EAAmB,EAA2B,EAAuB,CACjH,IAAM,EAAW,KAAK,YAAY,CAAM,EAClC,EAAK,EAAe,CAAC,EAC3B,OAAO,EAAc,GAAG,IAAW,IAAM,EAAS,KAAK,aAAa,CAAI,CAAC,EAG3E,OAAyB,CAAC,EAAiB,EAAwB,EAAuB,CACxF,IAAM,EAAW,KAAK,YAAY,CAAM,EACxC,OAAO,EAAgB,EAAU,EAAS,KAAK,aAAa,CAAI,CAAC,EAGnE,QAA0B,CAAC,EAAiB,EAA0B,EAAuB,CAC3F,IAAM,EAAW,KAAK,YAAY,CAAM,EACxC,OAAO,EAAkB,GAAG,IAAW,EAAY,SAAS,OAAQ,EAAS,KAAK,aAAa,CAAI,CAAC,EAGtG,aAA+B,CAAC,EAAiB,EAAgB,EAAsC,CAAC,EAAG,CACzG,IAAM,EAAW,KAAK,YAAY,CAAM,EAClC,EAAK,EAAK,WAAa,EAAe,CAAE,WAAY,EAAK,UAAW,CAAC,EAAI,GAC/E,OAAO,EAAe,GAAG,KAAY,IAAK,IAAM,KAAK,aAAa,CAAI,CAAC,EAGzE,UAA4B,CAAC,EAAiB,EAAmB,EAAsC,CAAC,EAAG,CACzG,IAAM,EAAW,KAAK,YAAY,CAAM,EAClC,EAAK,EAAe,EAAK,WAAa,IAAK,EAAG,WAAY,EAAK,UAAW,EAAI,CAAC,EACrF,OAAO,EAAe,GAAG,IAAW,IAAM,KAAK,aAAa,CAAI,CAAC,EAGnE,WAAc,CAAC,EAAiB,CAC9B,MAAO,GAAG,KAAK,YAAY,EAAW,CAAM,IAGpC,IAAO,CAAC,EAAc,EAAwC,EAAuB,CAC7F,GAAI,KAAK,SAAS,aAAe,QAC/B,OAAO,EAAa,EAAM,GAAK,CAAC,EAAG,KAAK,aAAa,CAAI,CAAC,EAE5D,OAAO,EAAO,GAAG,IAAO,EAAe,CAAC,IAAK,KAAK,aAAa,CAAI,CAAC,EAG5D,YAAY,CAAC,EAAmD,CACxE,GAAI,CAAC,KAAK,SAAS,SAAW,CAAC,GAAM,QACnC,OAAO,EAET,MAAO,IAAK,EAAM,QAAS,IAAK,KAAK,SAAS,WAAY,GAAM,OAAQ,CAAE,EAE9E,CCjJA,IAAI,EAAiC,CACnC,WAAY,IAAM,IAAI,EAAY,MAAM,CAC1C,EAEO,SAAS,CAA2C,CAAC,EAAS,CACnE,EAAc,EAGT,SAAS,CAAc,EAAsB,CAClD,OAAO,EAGF,SAAS,CAAU,EAAkB,CAC1C,OAAO,EAAe,EAAE,WAAW",
15
- "debugId": "63F5785EAD0E521C64756E2164756E21",
14
+ "mappings": "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",
15
+ "debugId": "BB4E96BEA0CE335F64756E2164756E21",
16
16
  "names": []
17
17
  }
@@ -134,6 +134,13 @@ export declare class MongoDialect extends AbstractDialect {
134
134
  * for themselves and each got a different part of it wrong.
135
135
  */
136
136
  readStages<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions, extra?: MongoReadStages): MongoAggregationPipelineEntry<Document>[];
137
+ /**
138
+ * `$distinct` as a `$group` on the columns the query projects - the same set a SQL dialect puts
139
+ * after `SELECT DISTINCT` - and the `$replaceRoot` that lifts the grouped key back to the top
140
+ * level. A query that projects nothing selects every column, primary key included, so there is
141
+ * nothing to collapse: `SELECT DISTINCT *` collapses nothing either.
142
+ */
143
+ private distinctStages;
137
144
  /**
138
145
  * The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
139
146
  * the joined documents, and the `_id` a to-many fill groups children by. It goes last, after the
@@ -214,6 +221,9 @@ export type MongoAggregationPipelineEntry<E extends Document> = {
214
221
  $unwind?: MongoAggregationUnwind;
215
222
  $group?: Record<string, unknown>;
216
223
  $project?: Record<string, unknown>;
224
+ $replaceRoot?: {
225
+ readonly newRoot: string | Record<string, unknown>;
226
+ };
217
227
  $addFields?: Record<string, unknown>;
218
228
  $vectorSearch?: Record<string, unknown>;
219
229
  $count?: string;
@@ -452,14 +452,40 @@ export class MongoDialect extends AbstractDialect {
452
452
  // A `$required` relation drops parents when it unwinds, and an ordering may read a field only a
453
453
  // lookup produces: either one puts the lookups first, as an INNER JOIN does. Otherwise paging
454
454
  // first is equivalent and spares the lookups the rows it cuts.
455
+ // Merged into the query's own projection rather than standing in for one: a query that asked
456
+ // for no columns wants the whole document, not just the field this adds to it.
457
+ const projected = projection ? { ...projection, ...extra.project } : undefined;
458
+ const project = projected ? [{ $project: projected }] : [];
459
+ // `$distinct` inverts the usual order twice over: the projection decides which columns make two
460
+ // rows the same, so it has to run *before* the grouping, and the grouping collapses rows, so the
461
+ // ordering and the page have to run after it to address the set the caller actually receives.
462
+ const dedup = q.$distinct ? this.distinctStages(projected) : [];
463
+ if (dedup.length) {
464
+ return [...lookups, ...project, ...dedup, ...sort, ...pager];
465
+ }
466
+ // A `$required` relation drops parents when it unwinds, and an ordering may read a field only a
467
+ // lookup produces: either one puts the lookups first, as an INNER JOIN does. Otherwise paging
468
+ // first is equivalent and spares the lookups the rows it cuts.
455
469
  const lookupsFirst = this.sortsRelations(entity, q.$sort) ||
456
470
  lookups.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
457
- return [
458
- ...(lookupsFirst ? [...lookups, ...sort, ...pager] : [...sort, ...pager, ...lookups]),
459
- // Merged into the query's own projection rather than standing in for one: a query that asked
460
- // for no columns wants the whole document, not just the field this adds to it.
461
- ...(projection ? [{ $project: { ...projection, ...extra.project } }] : []),
462
- ];
471
+ return [...(lookupsFirst ? [...lookups, ...sort, ...pager] : [...sort, ...pager, ...lookups]), ...project];
472
+ }
473
+ /**
474
+ * `$distinct` as a `$group` on the columns the query projects - the same set a SQL dialect puts
475
+ * after `SELECT DISTINCT` - and the `$replaceRoot` that lifts the grouped key back to the top
476
+ * level. A query that projects nothing selects every column, primary key included, so there is
477
+ * nothing to collapse: `SELECT DISTINCT *` collapses nothing either.
478
+ */
479
+ distinctStages(projection) {
480
+ if (!projection) {
481
+ return [];
482
+ }
483
+ const keys = getKeys(projection).filter((key) => projection[key] === 1);
484
+ if (!keys.length) {
485
+ return [];
486
+ }
487
+ const groupId = Object.fromEntries(keys.map((key) => [key, `$${key}`]));
488
+ return [{ $group: { _id: groupId } }, { $replaceRoot: { newRoot: '$_id' } }];
463
489
  }
464
490
  /**
465
491
  * The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
@@ -24,10 +24,11 @@ export declare class MongodbQuerier extends AbstractQuerier {
24
24
  protected internalAggregate<E extends Document, G extends QueryGroupMap<E>, A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
25
25
  protected internalCount<E extends Document>(entity: Type<E>, qm?: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
26
26
  /**
27
- * The `_id`s a search matches. Writes need this whenever the `$where` constrains a relation, since a
28
- * MongoDB `updateMany`/`deleteMany` filter cannot host a `$lookup` - reads never pay this cost.
27
+ * The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on.
28
+ * Built from the read pipeline rather than stages assembled here: that dropped `$sort`/`$limit` on
29
+ * the floor, and skipped the relation-sort rejection {@link MongoDialect.sort} raises.
29
30
  */
30
- private matchedIds;
31
+ private settleIds;
31
32
  internalInsertMany<E extends Document>(entity: Type<E>, payloads: EntityData<E>[]): Promise<IdValue<E>[]>;
32
33
  internalUpdateMany<E extends Document>(entity: Type<E>, qm: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
33
34
  private buildConflictFilter;
@@ -1,6 +1,6 @@
1
1
  import { getMeta } from '../entity/index.js';
2
2
  import { AbstractQuerier, enrichError } from '../querier/index.js';
3
- import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
3
+ import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
4
4
  export class MongodbQuerier extends AbstractQuerier {
5
5
  dialect;
6
6
  conn;
@@ -31,9 +31,10 @@ export class MongodbQuerier extends AbstractQuerier {
31
31
  }
32
32
  else {
33
33
  // A relation condition needs `$lookup`, so it forces the aggregation path just like populating
34
- // one does - and so does ordering by a relation, which reads what a lookup produced. A plain
35
- // `find` cursor can express none of the three.
36
- if (populatesRelations(meta, q.$populate) ||
34
+ // one does - and so does ordering by a relation, which reads what a lookup produced, and
35
+ // `$distinct`, which is a `$group`. A plain `find` cursor can express none of the four.
36
+ if (q.$distinct ||
37
+ populatesRelations(meta, q.$populate) ||
37
38
  this.dialect.constrainsRelations(entity, q.$where) ||
38
39
  this.dialect.sortsRelations(entity, q.$sort)) {
39
40
  const pipeline = this.dialect.aggregationPipeline(entity, q, opts);
@@ -143,15 +144,14 @@ export class MongodbQuerier extends AbstractQuerier {
143
144
  });
144
145
  }
145
146
  /**
146
- * The `_id`s a search matches. Writes need this whenever the `$where` constrains a relation, since a
147
- * MongoDB `updateMany`/`deleteMany` filter cannot host a `$lookup` - reads never pay this cost.
147
+ * The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on.
148
+ * Built from the read pipeline rather than stages assembled here: that dropped `$sort`/`$limit` on
149
+ * the floor, and skipped the relation-sort rejection {@link MongoDialect.sort} raises.
148
150
  */
149
- async matchedIds(entity, where, opts) {
151
+ async settleIds(entity, q, opts) {
150
152
  const meta = getMeta(entity);
151
- const { stages, filter } = this.dialect.whereWithRelations(entity, where, opts);
152
- const founds = await this.execute((session) => this.collection(entity)
153
- .aggregate([...stages, { $match: filter }, { $project: { _id: true } }], { session })
154
- .toArray());
153
+ const pipeline = this.dialect.aggregationPipeline(entity, idOnlyQuery(meta, q), opts);
154
+ const founds = await this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
155
155
  return (this.dialect.normalizeIds(meta, founds) || []).map((found) => found[meta.id]);
156
156
  }
157
157
  async internalInsertMany(entity, payloads) {
@@ -176,9 +176,11 @@ export class MongodbQuerier extends AbstractQuerier {
176
176
  payload = clone(payload);
177
177
  const meta = getMeta(entity);
178
178
  const persistable = this.dialect.getPersistable(meta, payload, 'onUpdate');
179
- // An `updateMany` filter cannot host a `$lookup`, so a relation condition is resolved to ids first.
180
- const where = this.dialect.constrainsRelations(entity, qm.$where)
181
- ? { _id: { $in: await this.matchedIds(entity, qm.$where, opts) } }
179
+ // Settled to ids first in two cases: an `updateMany` filter cannot host a `$lookup`, so a
180
+ // relation condition has nowhere to go, and MongoDB takes no page on a write, so a paged one
181
+ // has to name the rows it picked rather than touching every match.
182
+ const where = this.dialect.constrainsRelations(entity, qm.$where) || isPagedQuery(qm)
183
+ ? { _id: { $in: await this.settleIds(entity, qm, opts) } }
182
184
  : this.dialect.where(entity, qm.$where, opts);
183
185
  // Maps JSON operators ($set/$unset/$push/$pull) onto their native MongoDB equivalents.
184
186
  const update = this.dialect.getUpdateFilter(persistable);
@@ -252,8 +254,8 @@ export class MongodbQuerier extends AbstractQuerier {
252
254
  // Hard delete targets matching rows regardless of soft-delete state (keeps other filters).
253
255
  const findOpts = field ? opts : { ...opts, filters: withoutSoftDeleteFilter(opts.filters) };
254
256
  // Delete has always resolved its ids first (it stamps or removes them by `_id`), so a relation
255
- // condition needs nothing extra here.
256
- const ids = await this.matchedIds(entity, qm.$where, findOpts);
257
+ // condition needs nothing extra here - and passing the whole query is what makes its page apply.
258
+ const ids = await this.settleIds(entity, qm, findOpts);
257
259
  if (!ids.length) {
258
260
  return 0;
259
261
  }
@@ -125,7 +125,12 @@ export declare abstract class AbstractQuerier implements Querier {
125
125
  protected insertRelations<E extends object>(entity: Type<E>, payload: E[]): Promise<void>;
126
126
  protected updateRelations<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<void>;
127
127
  protected deleteRelations<E extends object>(entity: Type<E>, ids: IdValue<E>[], opts?: QueryOptions): Promise<void>;
128
- protected saveRelation<E extends object>(entity: Type<E>, payload: E, relKey: RelationKey<E>, isUpdate?: boolean): Promise<void>;
128
+ /**
129
+ * Persists `relValue` against every id in `ids`, which an update hands the whole page of rows it
130
+ * settled: the value is the same for all of them, so the statements are per relation rather than per
131
+ * row wherever the cardinality allows it.
132
+ */
133
+ protected saveRelation<E extends object>(entity: Type<E>, ids: IdValue<E>[], relValue: unknown, relKey: RelationKey<E>, isUpdate?: boolean): Promise<void>;
129
134
  private saveToMany;
130
135
  private saveOneToOne;
131
136
  private saveManyToOne;
@@ -1,5 +1,5 @@
1
1
  import { getMeta } from '../entity/index.js';
2
- import { asSelectMap, augmentWhere, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, LoggerWrapper, parseRelationAtKey, parseRelationQueryValue, runHooks, withoutSoftDeleteFilter, } from '../util/index.js';
2
+ import { asSelectMap, augmentWhere, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, LoggerWrapper, parseRelationAtKey, parseRelationQueryValue, runHooks, withoutSoftDeleteFilter, } from '../util/index.js';
3
3
  import { enrichError } from './queryError.js';
4
4
  /**
5
5
  * Rejects a nullish primary key before it reaches a statement. The by-id methods reduce to
@@ -314,7 +314,7 @@ export class AbstractQuerier {
314
314
  }, []);
315
315
  if (!entries.length)
316
316
  return;
317
- await Promise.all(entries.map(({ it, relKeys }) => Promise.all(relKeys.map((relKey) => this.saveRelation(entity, it, relKey)))));
317
+ await Promise.all(entries.map(({ it, relKeys }) => Promise.all(relKeys.map((relKey) => this.saveRelation(entity, [it[meta.id]], it[relKey], relKey)))));
318
318
  }
319
319
  async updateRelations(entity, q, payload, opts) {
320
320
  const meta = getMeta(entity);
@@ -322,9 +322,14 @@ export class AbstractQuerier {
322
322
  if (!relKeys.length) {
323
323
  return;
324
324
  }
325
- const founds = await this.findMany(entity, { ...q, $select: { [meta.id]: true } }, opts);
325
+ const founds = await this.findMany(entity, idOnlyQuery(meta, q), opts);
326
326
  const ids = founds.map((found) => found[meta.id]);
327
- await Promise.all(ids.map((id) => Promise.all(relKeys.map((relKey) => this.saveRelation(entity, { ...payload, [meta.id]: id }, relKey, true)))));
327
+ if (!ids.length) {
328
+ return;
329
+ }
330
+ for (const relKey of relKeys) {
331
+ await this.saveRelation(entity, ids, payload[relKey], relKey, true);
332
+ }
328
333
  }
329
334
  async deleteRelations(entity, ids, opts) {
330
335
  const meta = getMeta(entity);
@@ -346,66 +351,70 @@ export class AbstractQuerier {
346
351
  }
347
352
  }
348
353
  }
349
- async saveRelation(entity, payload, relKey, isUpdate) {
354
+ /**
355
+ * Persists `relValue` against every id in `ids`, which an update hands the whole page of rows it
356
+ * settled: the value is the same for all of them, so the statements are per relation rather than per
357
+ * row wherever the cardinality allows it.
358
+ */
359
+ async saveRelation(entity, ids, relValue, relKey, isUpdate) {
350
360
  const meta = getMeta(entity);
351
- const id = payload[meta.id];
352
361
  const relOpts = meta.relations[relKey];
353
362
  if (!relOpts)
354
363
  return;
355
364
  const relEntity = relOpts.entity();
356
- const relPayload = payload[relKey];
365
+ const relPayload = relValue;
357
366
  switch (relOpts.cardinality) {
358
367
  case '1m':
359
368
  case 'mm':
360
- return this.saveToMany(relOpts, relEntity, id, relPayload, isUpdate);
369
+ return this.saveToMany(relOpts, relEntity, ids, relPayload, isUpdate);
361
370
  case '11':
362
- return this.saveOneToOne(relEntity, relOpts, id, relPayload);
371
+ return this.saveOneToOne(relEntity, relOpts, ids, relPayload);
363
372
  case 'm1':
364
373
  if (relPayload)
365
- return this.saveManyToOne(entity, relEntity, relOpts, id, relPayload);
374
+ return this.saveManyToOne(entity, relEntity, relOpts, ids, relPayload);
366
375
  }
367
376
  }
368
- async saveToMany(relOpts, relEntity, id, relPayload, isUpdate) {
377
+ async saveToMany(relOpts, relEntity, ids, relPayload, isUpdate) {
369
378
  const { references, through } = relOpts;
370
379
  if (through) {
371
380
  const localField = references[0].local;
372
381
  const throughEntity = through();
373
382
  if (isUpdate) {
374
- await this.deleteMany(throughEntity, { $where: { [localField]: id } });
383
+ await this.deleteMany(throughEntity, { $where: { [localField]: ids } });
375
384
  }
376
385
  if (relPayload) {
377
- const savedIds = await this.saveMany(relEntity, relPayload);
378
- const throughBodies = savedIds.map((relId) => ({
379
- [references[0].local]: id,
380
- [references[1].local]: relId,
381
- }));
382
- await this.insertMany(throughEntity, throughBodies);
386
+ // Saved per parent on purpose: each one owns its copies of the children, and saving them once
387
+ // would link every parent to a single shared row instead.
388
+ for (const id of ids) {
389
+ const savedIds = await this.saveMany(relEntity, relPayload);
390
+ await this.insertMany(throughEntity, savedIds.map((relId) => ({ [references[0].local]: id, [references[1].local]: relId })));
391
+ }
383
392
  }
384
393
  return;
385
394
  }
386
395
  const foreignField = references[0].foreign;
387
396
  if (isUpdate) {
388
- await this.deleteMany(relEntity, { $where: { [foreignField]: id } });
397
+ await this.deleteMany(relEntity, { $where: { [foreignField]: ids } });
389
398
  }
390
399
  if (relPayload) {
391
- for (const it of relPayload) {
392
- it[foreignField] = id;
393
- }
394
- await this.saveMany(relEntity, relPayload);
400
+ await this.saveMany(relEntity, ids.flatMap((id) => relPayload.map((it) => ({ ...it, [foreignField]: id }))));
395
401
  }
396
402
  }
397
- async saveOneToOne(relEntity, relOpts, id, relPayload) {
403
+ async saveOneToOne(relEntity, relOpts, ids, relPayload) {
398
404
  const foreignField = relOpts.references[0].foreign;
399
405
  if (relPayload === null) {
400
- await this.deleteMany(relEntity, { $where: { [foreignField]: id } });
406
+ await this.deleteMany(relEntity, { $where: { [foreignField]: ids } });
401
407
  return;
402
408
  }
403
- await this.saveOne(relEntity, { ...relPayload, [foreignField]: id });
409
+ await this.saveMany(relEntity, ids.map((id) => ({ ...relPayload, [foreignField]: id })));
404
410
  }
405
- async saveManyToOne(entity, relEntity, relOpts, id, relPayload) {
411
+ async saveManyToOne(entity, relEntity, relOpts, ids, relPayload) {
406
412
  const localField = relOpts.references[0].local;
407
- const referenceId = await this.insertOne(relEntity, relPayload);
408
- await this.updateOneById(entity, id, { [localField]: referenceId });
413
+ // Per parent: each gets its own reference row, so each `SET` carries a different value.
414
+ for (const id of ids) {
415
+ const referenceId = await this.insertOne(relEntity, relPayload);
416
+ await this.updateOneById(entity, id, { [localField]: referenceId });
417
+ }
409
418
  }
410
419
  /**
411
420
  * Runs `callback` in a transaction: begin, commit on success, roll back on failure.
@@ -58,8 +58,10 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
58
58
  * populated under it. Which columns, and as what, is `hydratableFields`; the per-cell decode is
59
59
  * `decodeColumn`. Both live with the dialect, because a `sparsevec` is only sparse on Postgres.
60
60
  *
61
- * `visited` guards a populated graph that points back at itself; it defaults rather than living in
62
- * a separate entry-point wrapper, because the wrapper's whole body was seeding it.
61
+ * `visited` guards a populated graph that points back at itself, and makes a node two paths reach
62
+ * decode once. Only a relation can lead the walk back somewhere it has been, so an entity that
63
+ * declares none skips the guard rather than allocating a set per row to hold a single object -
64
+ * which cost more than the decoding it guards, on a flat read.
63
65
  */
64
66
  private hydrateFields;
65
67
  protected internalCount<E extends object>(entity: Type<E>, q?: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
@@ -1,16 +1,8 @@
1
1
  import { decodeColumn } from '../dialect/hydrateColumn.js';
2
2
  import { getMeta } from '../entity/index.js';
3
- import { buildUpdateResult, cascadesOnDelete, clone, getInsertFieldKeys, getRelationRequestSummary, isAutoIncrement, obtainAttrsPaths, throwNoPendingTransaction, throwPendingTransaction, unflatObject, unflatObjects, withoutSoftDeleteFilter, } from '../util/index.js';
3
+ import { buildUpdateResult, cascadesOnDelete, clone, getInsertFieldKeys, getRelationRequestSummary, hasKeys, idOnlyQuery, isAutoIncrement, isPagedQuery, obtainAttrsPaths, throwNoPendingTransaction, throwPendingTransaction, unflatObject, unflatObjects, withoutSoftDeleteFilter, } from '../util/index.js';
4
4
  import { AbstractQuerier } from './abstractQuerier.js';
5
5
  import { enrichError } from './queryError.js';
6
- /**
7
- * Whether `q` picks a specific slice of the matching rows rather than all of them. A write that
8
- * does has to settle those rows and name them: `ORDER BY`/`LIMIT` on an UPDATE or DELETE is MySQL's
9
- * alone.
10
- */
11
- function isPaged(q) {
12
- return q.$sort !== undefined || q.$limit !== undefined || q.$skip !== undefined;
13
- }
14
6
  export class AbstractSqlQuerier extends AbstractQuerier {
15
7
  dialect;
16
8
  extra;
@@ -130,14 +122,15 @@ export class AbstractSqlQuerier extends AbstractQuerier {
130
122
  * populated under it. Which columns, and as what, is `hydratableFields`; the per-cell decode is
131
123
  * `decodeColumn`. Both live with the dialect, because a `sparsevec` is only sparse on Postgres.
132
124
  *
133
- * `visited` guards a populated graph that points back at itself; it defaults rather than living in
134
- * a separate entry-point wrapper, because the wrapper's whole body was seeding it.
125
+ * `visited` guards a populated graph that points back at itself, and makes a node two paths reach
126
+ * decode once. Only a relation can lead the walk back somewhere it has been, so an entity that
127
+ * declares none skips the guard rather than allocating a set per row to hold a single object -
128
+ * which cost more than the decoding it guards, on a flat read.
135
129
  */
136
- hydrateFields(entity, dto, visited = new WeakSet()) {
137
- if (!dto || typeof dto !== 'object' || visited.has(dto)) {
130
+ hydrateFields(entity, dto, visited) {
131
+ if (!dto || typeof dto !== 'object' || visited?.has(dto)) {
138
132
  return dto;
139
133
  }
140
- visited.add(dto);
141
134
  const meta = getMeta(entity);
142
135
  const row = dto;
143
136
  for (const [key, kind] of this.dialect.hydratableFields(entity)) {
@@ -146,21 +139,30 @@ export class AbstractSqlQuerier extends AbstractQuerier {
146
139
  row[key] = decodeColumn(value, kind);
147
140
  }
148
141
  }
142
+ // Allocated only where the walk can continue: an entity declaring no relation cannot lead back
143
+ // to a node already decoded, and the loop below is a no-op for it anyway.
144
+ if (hasKeys(meta.relations)) {
145
+ visited ??= new WeakSet();
146
+ }
147
+ visited?.add(dto);
148
+ // The value is read before the relation's target is resolved: a query that populated nothing
149
+ // still walks every relation the entity declares, and `rel.entity()` is a call per row per
150
+ // relation that only the populated ones need.
149
151
  for (const key in meta.relations) {
152
+ const value = row[key];
153
+ if (!value || typeof value !== 'object')
154
+ continue;
150
155
  const rel = meta.relations[key];
151
156
  if (!rel)
152
157
  continue;
153
158
  const relEntity = rel.entity();
154
- const value = row[key];
155
159
  if (Array.isArray(value)) {
156
160
  for (const it of value) {
157
161
  this.hydrateFields(relEntity, it, visited);
158
162
  }
159
163
  continue;
160
164
  }
161
- if (value && typeof value === 'object') {
162
- this.hydrateFields(relEntity, value, visited);
163
- }
165
+ this.hydrateFields(relEntity, value, visited);
164
166
  }
165
167
  return dto;
166
168
  }
@@ -230,7 +232,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
230
232
  // Settled first for the reason `internalDeleteMany` settles: `ORDER BY`/`LIMIT` on an UPDATE is
231
233
  // MySQL's alone, so a paged update has to name the rows it picked.
232
234
  let target = q;
233
- if (isPaged(q)) {
235
+ if (isPagedQuery(q)) {
234
236
  const ids = await this.settleIds(entity, q, opts);
235
237
  if (!ids.length) {
236
238
  return 0;
@@ -247,7 +249,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
247
249
  async settleIds(entity, q, opts) {
248
250
  const meta = getMeta(entity);
249
251
  const ctx = this.dialect.createContext();
250
- this.dialect.find(ctx, entity, { ...q, $select: { [meta.id]: true } }, opts);
252
+ this.dialect.find(ctx, entity, idOnlyQuery(meta, q), opts);
251
253
  const founds = await this.all(ctx.sql, ctx.values);
252
254
  return founds.map((it) => it[meta.id]);
253
255
  }
@@ -278,7 +280,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
278
280
  // parents' ids to find their children, and no engine but MySQL accepts `ORDER BY`/`LIMIT` on a
279
281
  // DELETE, so a paged delete has to name the rows it settled on. A plain predicate needs neither,
280
282
  // and there the round trip buys nothing: the statement can say what the caller already said.
281
- if (!isPaged(q) && !cascadesOnDelete(meta)) {
283
+ if (!isPagedQuery(q) && !cascadesOnDelete(meta)) {
282
284
  const ctx = this.dialect.createContext();
283
285
  this.dialect.delete(ctx, entity, q, opts);
284
286
  const { changes = 0 } = await this.run(ctx.sql, ctx.values);
@@ -1,4 +1,4 @@
1
- import { type CascadeType, type EntityData, type EntityMeta, type FieldKey, type FieldOptions, type JsonUpdateOp, type OnFieldCallback, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QueryVectorSearch, type QueryWhere, type QueryWhereMap, type RelationKey } from '../type/index.js';
1
+ import { type CascadeType, type EntityData, type EntityMeta, type FieldKey, type FieldOptions, type JsonUpdateOp, type OnFieldCallback, type Query, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySearch, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QueryVectorSearch, type QueryWhere, type QueryWhereMap, type RelationKey } from '../type/index.js';
2
2
  export type CallbackKey = keyof Pick<FieldOptions, 'onInsert' | 'onUpdate'>;
3
3
  export declare function filterFieldKeys<E>(meta: EntityMeta<E>, payload: EntityData<E>, callbackKey: CallbackKey): FieldKey<E>[];
4
4
  /**
@@ -41,6 +41,19 @@ export declare function filterPersistableRelationKeys<E>(meta: EntityMeta<E>, pa
41
41
  */
42
42
  export declare function cascadesOnDelete<E>(meta: EntityMeta<E>): boolean;
43
43
  export declare function isCascadable(action: CascadeType, configuration?: boolean | CascadeType): boolean;
44
+ /**
45
+ * Whether `q` carries an ordering or a page, so a write has to settle its rows with a read and name
46
+ * them by id. `$sort` counts even without a page: the SQL dialects emit it from the same `search()`
47
+ * that `find` uses, and SQLite rejects `ORDER BY` on an UPDATE that has no `LIMIT`. MongoDB takes
48
+ * neither clause on a write at all.
49
+ */
50
+ export declare function isPagedQuery<E>(q: QuerySearch<E>): boolean;
51
+ /**
52
+ * `q` selecting nothing but the id: what a write hands its backend's own read builder to settle the
53
+ * rows it will name. The cast is unavoidable - a computed key is not a `QuerySelect` key to the
54
+ * compiler - so it is spelled once here rather than in each querier.
55
+ */
56
+ export declare function idOnlyQuery<E>(meta: EntityMeta<E>, q: QuerySearch<E>): Query<E>;
44
57
  /**
45
58
  * The map form of a `$select` value, or `undefined` for the raw-array form. Centralizes the one
46
59
  * narrowing cast: `Array.isArray` does not narrow `readonly` arrays out of a union.
@@ -96,6 +96,23 @@ export function isCascadable(action, configuration) {
96
96
  }
97
97
  return configuration === action;
98
98
  }
99
+ /**
100
+ * Whether `q` carries an ordering or a page, so a write has to settle its rows with a read and name
101
+ * them by id. `$sort` counts even without a page: the SQL dialects emit it from the same `search()`
102
+ * that `find` uses, and SQLite rejects `ORDER BY` on an UPDATE that has no `LIMIT`. MongoDB takes
103
+ * neither clause on a write at all.
104
+ */
105
+ export function isPagedQuery(q) {
106
+ return q.$sort !== undefined || q.$limit !== undefined || q.$skip !== undefined;
107
+ }
108
+ /**
109
+ * `q` selecting nothing but the id: what a write hands its backend's own read builder to settle the
110
+ * rows it will name. The cast is unavoidable - a computed key is not a `QuerySelect` key to the
111
+ * compiler - so it is spelled once here rather than in each querier.
112
+ */
113
+ export function idOnlyQuery(meta, q) {
114
+ return { ...q, $select: { [meta.id]: true } };
115
+ }
99
116
  /**
100
117
  * The map form of a `$select` value, or `undefined` for the raw-array form. Centralizes the one
101
118
  * narrowing cast: `Array.isArray` does not narrow `readonly` arrays out of a union.
@@ -4,13 +4,11 @@ export declare function throwNoPendingTransaction(): never;
4
4
  export declare function clone<T>(value: T): T;
5
5
  /** Whether `obj` has at least one enumerable key. Narrows away `undefined`/`null` for callers. */
6
6
  export declare function hasKeys<T>(obj: T): obj is NonNullable<T>;
7
- /** Whether `obj` has at least two enumerable keys. */
8
- export declare function hasMultipleKeys(obj: object): boolean;
9
7
  /**
10
8
  * Whether any enumerable key of `obj` satisfies `pred`, short-circuiting on the first match
11
9
  * without materializing a key array (unlike `Object.keys(obj).some(pred)`).
12
10
  */
13
- export declare function someKey(obj: object, pred: (key: string) => boolean): boolean;
11
+ export declare function someKey<T extends object>(obj: T, pred: (key: keyof T & string) => boolean): boolean;
14
12
  /** Whether any enumerable value of `obj` satisfies `pred`, short-circuiting like {@link someKey}. */
15
13
  export declare function someValue(obj: object, pred: (value: unknown) => boolean): boolean;
16
14
  /**
@@ -21,15 +21,6 @@ export function hasKeys(obj) {
21
21
  return true;
22
22
  return false;
23
23
  }
24
- /** Whether `obj` has at least two enumerable keys. */
25
- export function hasMultipleKeys(obj) {
26
- let count = 0;
27
- for (const _ in obj) {
28
- if (++count > 1)
29
- return true;
30
- }
31
- return false;
32
- }
33
24
  /**
34
25
  * Whether any enumerable key of `obj` satisfies `pred`, short-circuiting on the first match
35
26
  * without materializing a key array (unlike `Object.keys(obj).some(pred)`).
@@ -1,4 +1,4 @@
1
- import { getKeys } from './object.util.js';
1
+ import { getKeys, someKey } from './object.util.js';
2
2
  /**
3
3
  * Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. Takes
4
4
  * the one field it reads, so it answers for a relation being declared as well as for a resolved one.
@@ -58,7 +58,7 @@ export function getRelationRequestSummary(meta, populate) {
58
58
  export function populatesRelations(meta, populate) {
59
59
  if (!populate)
60
60
  return false;
61
- return getKeys(populate).some((key) => populate[key] && key in meta.relations);
61
+ return someKey(populate, (key) => !!populate[key] && key in meta.relations);
62
62
  }
63
63
  // Keep in sync with `Query`'s own keys (`type/query.ts`); `$where`'s value type is `QueryWhere` (`type/queryWhere.ts`).
64
64
  const RELATION_QUERY_BOOLEAN_KEYS = new Set(['$distinct', '$required']);
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.31.3",
6
+ "version": "0.31.4",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"