@stacksjs/orm 0.75.109 → 0.75.111

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.
Files changed (48) hide show
  1. package/dist/api-routes.js +0 -0
  2. package/dist/auto-crud.js +1 -0
  3. package/dist/batch-loader.js +1 -0
  4. package/dist/builder.d.ts +68 -0
  5. package/dist/builder.js +0 -0
  6. package/dist/db.js +1 -0
  7. package/dist/define-model.js +1 -0
  8. package/dist/extend-model.js +1 -0
  9. package/dist/gdpr/engine.js +1 -0
  10. package/dist/gdpr/index.js +1 -0
  11. package/dist/gdpr/models.js +1 -0
  12. package/dist/gdpr/plan.js +3 -0
  13. package/dist/gdpr/register.js +4 -0
  14. package/dist/gdpr/types.js +0 -0
  15. package/dist/generate-database-schema.js +21 -0
  16. package/dist/generated/index.d.ts +1 -0
  17. package/dist/generated/index.js +1 -0
  18. package/dist/generated/table-traits.d.ts +8 -0
  19. package/dist/generated/table-traits.js +135 -0
  20. package/dist/generated/types.d.ts +1 -0
  21. package/dist/generated/types.js +0 -0
  22. package/dist/index.js +1 -27
  23. package/dist/migration-tables.js +1 -0
  24. package/dist/model-registry.js +1 -2
  25. package/dist/model-types.js +0 -0
  26. package/dist/ownership.js +1 -0
  27. package/dist/paginator-request.js +1 -0
  28. package/dist/paginator.js +1 -0
  29. package/dist/routes.js +1 -2
  30. package/dist/subquery.js +1 -0
  31. package/dist/traits/activity-log.js +1 -0
  32. package/dist/traits/actor.js +1 -0
  33. package/dist/traits/audit.js +1 -0
  34. package/dist/traits/billable.js +1 -0
  35. package/dist/traits/categorizable.js +1 -0
  36. package/dist/traits/commentable.js +1 -0
  37. package/dist/traits/index.d.ts +11 -0
  38. package/dist/traits/index.js +1 -0
  39. package/dist/traits/likeable.js +1 -0
  40. package/dist/traits/soft-deletes.js +1 -0
  41. package/dist/traits/taggable.js +1 -0
  42. package/dist/traits/two-factor.js +1 -0
  43. package/dist/transaction.js +1 -0
  44. package/dist/types.js +0 -0
  45. package/dist/utils/compiled-binary.js +1 -0
  46. package/dist/utils/encrypted.js +1 -0
  47. package/dist/utils/prunable.js +1 -0
  48. package/package.json +24 -24
File without changes
@@ -0,0 +1 @@
1
+ export{isUniqueViolation}from"@stacksjs/error-handling";import{isUniqueViolation}from"@stacksjs/error-handling";export function mapWriteError(err,modelName,op){const e=err;if(err instanceof Error&&typeof e.status==="number"&&Number.isInteger(e.status)&&e.status>=400&&e.status<600){const body={error:err.message};if(e.details!==void 0)body.details=e.details;return{status:e.status,body}}if(isUniqueViolation(err))return{status:409,body:{error:`${modelName} already exists`}};return{status:500,body:{error:`Failed to ${op} ${modelName}`,detail:String(err)}}}export function toSnakeCase(s){let hasUppercase=!1;for(let index=0;index<s.length;index++){const code=s.charCodeAt(index);if(code>127)return s.replace(/([a-z\d])([A-Z])/g,"$1_$2").replace(/([A-Z])([A-Z][a-z])/g,"$1_$2").toLowerCase();if(code>=65&&code<=90)hasUppercase=!0}if(!hasUppercase)return s;let result="";for(let index=0;index<s.length;index++){const code=s.charCodeAt(index);if(code<65||code>90){result+=s[index];continue}const previous=index>0?s.charCodeAt(index-1):0,next=index+1<s.length?s.charCodeAt(index+1):0,followsWord=previous>=97&&previous<=122||previous>=48&&previous<=57,endsAcronym=previous>=65&&previous<=90&&next>=97&&next<=122;if(index>0&&(followsWord||endsAcronym))result+="_";result+=String.fromCharCode(code+32)}return result}export function toSnakeCaseKeys(data){const out={};for(const[k,v]of Object.entries(data))out[toSnakeCase(k)]=v;return out}export function apiBasePath(uri,prefix){const cleanPrefix=String(prefix||"").replace(/^\/+|\/+$/g,""),cleanUri=String(uri).replace(/^\/+/,"");return`/api/${cleanPrefix?`${cleanPrefix}/`:""}${cleanUri}`}export function teamOwnershipField(model){if(!model)return null;const attributes=model.attributes??{};if(Object.prototype.hasOwnProperty.call(attributes,"teamId")||Object.prototype.hasOwnProperty.call(attributes,"team_id"))return"team_id";return model.belongsTo?.some((relation)=>relation==="Team")?"team_id":null}export function userOwnershipField(model){if(!model)return null;const attributes=model.attributes??{};if(Object.prototype.hasOwnProperty.call(attributes,"userId")||Object.prototype.hasOwnProperty.call(attributes,"user_id"))return"user_id";return model.belongsTo?.some((relation)=>relation==="User")?"user_id":null}export function ownershipDeclaredUnscoped(model){return model?.ownership===!1}export function stampOwnership(data,field,value){const ownerKey=Object.keys(data).find((key)=>toSnakeCase(key)===toSnakeCase(field));if(Array.isArray(value)){if(ownerKey===void 0)return{data,error:"Ownership value is required"};if(!value.some((allowed)=>String(allowed)===String(data[ownerKey])))return{data,error:"Ownership value is not available to this caller"};return{data:{...data,[field]:data[ownerKey]}}}if(value===null||value===void 0)return{data,error:"Caller has no ownership identity"};const stamped={...data};for(const key of Object.keys(stamped))if(toSnakeCase(key)===toSnakeCase(field))delete stamped[key];stamped[field]=value;return{data:stamped}}export function getWritableFields(model){if(!model)return[];const fields=Object.entries(model.attributes??{}).filter(([,attribute])=>attribute?.fillable===!0).map(([name])=>name);for(const relation of model.belongsTo??[]){if(typeof relation!=="string"||!relation.trim())continue;const words=toSnakeCase(relation.trim()).split("_").filter(Boolean);if(words.length===0)continue;const relationField=`${words[0]}${words.slice(1).map((word)=>`${word[0]?.toUpperCase()}${word.slice(1)}`).join("")}Id`;fields.push(relationField)}return[...new Set(fields)]}export function filterFillable(body,fillableFields){if(!body||fillableFields.length===0)return{};const result={};let snakeByField;for(const field of fillableFields){if(field in body){result[field]=body[field];continue}snakeByField??=resolveFieldSpellingMap(fillableFields);const snake=snakeByField.get(field);if(snake!==field&&snake in body)result[field]=body[snake]}return result}export function normalizeValidationValue(rule,value){if(rule?.name!=="date"||value instanceof Date||typeof value!=="string")return value;const match=/^(\d{4})-(\d{2})-(\d{2})$/.exec(value);if(!match)return value;const year=Number(match[1]),month=Number(match[2]),day=Number(match[3]),parsed=new Date(Date.UTC(year,month-1,day));if(parsed.getUTCFullYear()!==year||parsed.getUTCMonth()!==month-1||parsed.getUTCDate()!==day)return value;return parsed}const fieldSpellingsByList=new WeakMap,fieldSpellingMaps=new WeakMap;function resolveFieldSpellings(fields){const cached=fieldSpellingsByList.get(fields);if(cached&&cached.length===fields.length){let unchanged=!0;for(let index=0;index<fields.length;index++)if(cached[index]?.field!==fields[index]){unchanged=!1;break}if(unchanged)return cached}const resolved=fields.map((field)=>({field,snake:toSnakeCase(field)}));fieldSpellingsByList.set(fields,resolved);return resolved}function resolveFieldSpellingMap(fields){const spellings=resolveFieldSpellings(fields),cached=fieldSpellingMaps.get(spellings);if(cached)return cached;const resolved=new Map(spellings.map(({field,snake})=>[field,snake]));fieldSpellingMaps.set(spellings,resolved);return resolved}export function dropHiddenInputs(data,hiddenFields){if(!hiddenFields.length)return data;const out={...data};for(const{field,snake}of resolveFieldSpellings(hiddenFields)){delete out[field];if(snake!==field)delete out[snake]}return out}export function stripHidden(record,hiddenFields){if(!record||hiddenFields.length===0)return record;const result={...record};for(const{field,snake}of resolveFieldSpellings(hiddenFields)){delete result[field];if(snake!==field)delete result[snake]}return result}export const SYSTEM_COLUMNS=["id","uuid","created_at","updated_at","deleted_at"];export function buildReadColumnMap(attributes,hiddenFields){const map=new Map;for(const name of[...Object.keys(attributes??{}),...SYSTEM_COLUMNS]){const column=toSnakeCase(name);if(!/^\w+$/.test(column))continue;map.set(name,column);map.set(column,column)}for(const f of hiddenFields){map.delete(f);map.delete(toSnakeCase(f))}return map}export function applySorting(query,sortParam,columns){if(!sortParam)return query;let q=query;for(const rawToken of String(sortParam).split(",")){const tok=rawToken.trim();if(!tok)continue;const desc=tok.startsWith("-"),requested=desc?tok.slice(1):tok;if(!/^\w+$/.test(requested))continue;const column=columns.get(requested);if(!column)continue;q=q.orderBy(column,desc?"desc":"asc")}return q}export const AUTO_CRUD_CASTERS={string:{get:(v)=>v!=null?String(v):null,set:(v)=>v!=null?String(v):null},number:{get:(v)=>v!=null?Number(v):null,set:(v)=>v!=null?Number(v):null},integer:{get:(v)=>v!=null?Math.trunc(Number(v)):null,set:(v)=>v!=null?Math.trunc(Number(v)):null},float:{get:(v)=>v!=null?Number.parseFloat(String(v)):null,set:(v)=>v!=null?Number.parseFloat(String(v)):null},boolean:{get:(v)=>v===1||v==="1"||v===!0||v==="true",set:(v)=>v===!0||v===1||v==="1"||v==="true"?1:0},json:{get:(v)=>v==null?null:typeof v==="string"?safeJSON(v):v,set:(v)=>v==null?null:typeof v==="string"?v:JSON.stringify(v)},datetime:{get:(v)=>v?new Date(v):null,set:(v)=>v instanceof Date?v.toISOString():v},date:{get:(v)=>v?new Date(v):null,set:(v)=>v instanceof Date?v.toISOString().split("T")[0]:v},array:{get:(v)=>v==null?[]:Array.isArray(v)?v:typeof v==="string"?safeJSONOrEmpty(v):[],set:(v)=>v==null?null:Array.isArray(v)?JSON.stringify(v):v}};const autoCrudCastsByDefinition=new WeakMap;function resolveAutoCrudCasts(casts,attrs){const cached=autoCrudCastsByDefinition.get(casts);if(cached&&cached.length===attrs.length){let unchanged=!0;for(let index=0;index<attrs.length;index++){const attr=attrs[index];if(cached[index]?.attr!==attr||cached[index]?.definition!==casts[attr]){unchanged=!1;break}}if(unchanged)return cached}const resolved=[];for(const attr of attrs){const castDef=casts[attr],caster=typeof castDef==="string"?AUTO_CRUD_CASTERS[castDef]:castDef;resolved.push({attr,snake:toSnakeCase(attr),definition:castDef,caster})}autoCrudCastsByDefinition.set(casts,resolved);return resolved}function safeJSON(s){try{return JSON.parse(s)}catch{return s}}function safeJSONOrEmpty(_s){try{return JSON.parse(_s)}catch{return[]}}export function applyCasts(record,casts,direction){if(!record||typeof record!=="object"||!casts)return record;const attrs=Object.keys(casts);if(attrs.length===0)return record;const resolvedCasts=resolveAutoCrudCasts(casts,attrs);if(resolvedCasts.length===0)return record;const out={...record};for(const{attr,caster,snake}of resolvedCasts){if(!caster)continue;const cast=caster[direction];if(typeof cast!=="function")continue;if(Object.prototype.hasOwnProperty.call(out,attr))out[attr]=cast.call(caster,out[attr]);if(snake!==attr&&Object.prototype.hasOwnProperty.call(out,snake))out[snake]=cast.call(caster,out[snake])}return out}const writeValidatorsByAttributes=new WeakMap;function resolveWriteValidators(attributes){const cached=writeValidatorsByAttributes.get(attributes);if(cached)return cached;const validators=[];for(const[field,definition]of Object.entries(attributes)){const rule=definition?.validation?.rule;if(!rule||typeof rule.validate!=="function")continue;validators.push({definition,field,hasDefault:definition!==null&&typeof definition==="object"&&Object.prototype.hasOwnProperty.call(definition,"default"),rule})}writeValidatorsByAttributes.set(attributes,validators);return validators}export function hasWriteValidators(model){const attributes=model?.attributes;if(!attributes||typeof attributes!=="object")return!1;for(const definition of Object.values(attributes)){const rule=definition?.validation?.rule;if(rule&&typeof rule.validate==="function")return!0}return!1}export function validateWriteBody(data,model,hook){const attributes=model?.attributes;if(!attributes||typeof attributes!=="object")return{valid:!0};const validators=resolveWriteValidators(attributes);if(validators.length===0)return{valid:!0};const errors={};let hasErrors=!1;for(const validator of validators){const{definition,field,hasDefault,rule}=validator,present=Object.prototype.hasOwnProperty.call(data,field);if(!present&&hook==="updating")continue;const raw=present?data[field]:hasDefault?definition.default:void 0,value=normalizeValidationValue(rule,raw),result=rule.validate(value);if(!result?.valid&&Array.isArray(result?.errors)&&result.errors.length>0){hasErrors=!0;errors[field]=result.errors.map((e)=>definition.validation?.message?.[e?.code]??e?.message??"invalid")}}return hasErrors?{valid:!1,errors}:{valid:!0}}export function routeShapeKey(method,path){return`${method}\x00${routeShape(path)}`}export function indexRouteShapes(routes){const index=new Map;for(const route of routes){const key=routeShapeKey(String(route.method??""),String(route.path??""));if(!index.has(key))index.set(key,route)}return index}export function findShadowingRoute(routes,method,path){const shape=routeShape(path);return routes.find((r)=>r.method===method&&routeShape(String(r.path??""))===shape)}export function routeShape(path){return path.replace(/\{[^}]*\}/g,"{}").replace(/:[^/]+/g,"{}")}function middlewareList(raw){if(Array.isArray(raw))return raw.filter((m)=>typeof m==="string"&&m.length>0);return typeof raw==="string"&&raw?[raw]:[]}export function resolveApiMiddleware(useApi,dashboard){const declared=typeof useApi==="object"&&useApi!==null&&"middleware"in useApi,raw=useApi?.middleware,role=dashboardRoleMiddleware(dashboard);if(!declared)return{read:withDashboardRole(["auth"],role),write:withDashboardRole(["auth"],role),declared:!1};if(raw&&typeof raw==="object"&&!Array.isArray(raw)){const split=raw;return{read:withDashboardRole("read"in split?middlewareList(split.read):["auth"],role),write:withDashboardRole("write"in split?middlewareList(split.write):["auth"],role),declared:!0}}const list=middlewareList(raw);return{read:withDashboardRole(list,role),write:withDashboardRole(list,role),declared:!0}}export function dashboardRoleMiddleware(dashboard){if(!dashboard||typeof dashboard!=="object")return null;const options=dashboard;if(options.enforce===!1)return null;if(!Array.isArray(options.roles))return null;const roles=options.roles.filter((role)=>typeof role==="string").map((role)=>role.trim()).filter((role)=>role.length>0);return roles.length>0?`role:${roles.join(",")}`:null}export const API_ABILITIES=["index","show","store","update","destroy"];const ABILITY_SIDE={index:"read",show:"read",store:"write",update:"write",destroy:"write"};export function resolveAbilityMiddleware(useApi,dashboard){const{read,write,declared}=resolveApiMiddleware(useApi,dashboard),role=dashboardRoleMiddleware(dashboard),raw=useApi?.middleware,declaredPerAbility=raw&&typeof raw==="object"&&!Array.isArray(raw)?raw:void 0,abilities={};for(const ability of API_ABILITIES)abilities[ability]=declaredPerAbility&&ability in declaredPerAbility?withDashboardRole(middlewareList(declaredPerAbility[ability]),role):ABILITY_SIDE[ability]==="read"?read:write;return{abilities,read,write,declared}}function withDashboardRole(list,role){if(!role||list.length===0)return list;if(list.some((entry)=>entry==="role"||entry.startsWith("role:")))return list;return[...list,role]}export function describeContradictoryRoleGates(entries){if(entries.length===0)return null;const named=[...entries].sort((a,b)=>a.model.localeCompare(b.model)).map((entry)=>`${entry.model} (${[...entry.abilities].join(", ")})`).join(", ");return`[orm] ${entries.length} model(s) role-gate their sidebar row and publish part of the matching API to everyone, because \`useApi.middleware\` declares those abilities empty - ${named}. Drop the empty list to let \`dashboard.roles\` gate them, or set \`dashboard.enforce: false\` to keep the row hidden and the API public.`}export const POLICY_ABILITY={index:"viewAny",show:"view",store:"create",update:"update",destroy:"delete"};export async function policyDecision(modelName,ability,deps,subject){let gate;try{gate=await deps.gate()}catch{return null}if(!gate||!gate.hasPolicy(modelName))return null;try{const user=await deps.user(),verdict=subject===void 0?await gate.inspectFor(modelName,ability,user):await gate.inspectFor(modelName,ability,user,subject);if(verdict&&!verdict.isAllowed)return{status:403,message:verdict.message||"This action is unauthorized."};return null}catch{return{status:403,message:"This action is unauthorized."}}}export const INDEX_DEFAULT_PER_PAGE=15,INDEX_MAX_PER_PAGE=100;export function resolveIndexPageArgs(params){const pageRaw=Number.parseInt(params.get("page")||String(1),10),page=Number.isFinite(pageRaw)?Math.max(1,pageRaw):1,perPageRaw=Number.parseInt(params.get("per_page")||String(INDEX_DEFAULT_PER_PAGE),10),perPage=Math.min(Number.isFinite(perPageRaw)?Math.max(1,perPageRaw):INDEX_DEFAULT_PER_PAGE,INDEX_MAX_PER_PAGE);return{page,perPage,offset:(page-1)*perPage}}function pageUrl(url,page){const out=new URL(url.toString());out.searchParams.set("page",String(page));return`${out.pathname}${out.search}`}export function buildIndexMeta(url,page,perPage,rowCount,hasMore,total){const offset=(page-1)*perPage,empty=rowCount===0,meta={page,per_page:perPage,from:empty?null:offset+1,to:empty?null:offset+rowCount,has_more_pages:hasMore,prev_page_url:page>1?pageUrl(url,page-1):null,next_page_url:hasMore?pageUrl(url,page+1):null};if(total!==void 0&&!Number.isNaN(total)){const lastPage=Math.max(1,Math.ceil(total/perPage));meta.total=total;meta.last_page=lastPage;meta.first_page_url=pageUrl(url,1);meta.last_page_url=pageUrl(url,lastPage)}return meta}export function buildIndexPaginator(url,page,perPage,rowCount,hasMore,total){const{page:currentPage,...rest}=buildIndexMeta(url,page,perPage,rowCount,hasMore,total);return{current_page:currentPage,...rest}}export function resolveRowScopingPolicy(configured,envOverride){return(envOverride??configured??"").toString().trim().toLowerCase()==="warn"?"warn":"deny"}export function describeUnscopedMutatingModels(modelNames,policy){if(modelNames.length===0)return null;const names=[...modelNames].sort().join(", ");if(policy==="deny")return`[orm] security.api.rowScoping is 'deny': NOT registering store/update/destroy for ${modelNames.length} model(s) `+`with no row-level scoping \u2014 ${names}. Give a model an \`ownership\` config or a \`team_id\` column to restore its writes.`;return`[orm] ${modelNames.length} model(s) publish mutating routes with no row-level scoping, so an authenticated caller can write any row \u2014 `+`${names}. Scope one with an \`ownership\` config or a \`team_id\` column; intended for public catalog tables. Set \`security.api.rowScoping: 'deny'\` (config/security.ts) to stop generating these routes instead.`}
@@ -0,0 +1 @@
1
+ import{getCurrentRequest}from"@stacksjs/router";const requests=new WeakMap;function indexRows(rows){const byKey=new Map;for(const row of rows){const id=row?.id;if(id!==void 0)byKey.set(id,row)}return byKey}async function loadSingle(model,key){if(typeof model.find==="function")return model.find(key);const rows=await model.findMany?.([key]);return indexRows(rows??[]).get(key)}async function drainBatch(model,state,batch){state.pending=void 0;try{const rows=typeof model.findMany==="function"?await model.findMany(batch.keys):await Promise.all(batch.keys.map((key)=>model.find?.(key))),byKey=indexRows(rows??[]);for(const entry of batch.resolvers)entry.resolve(byKey.get(entry.key))}catch(error){for(const entry of batch.resolvers)entry.reject(error)}}export function batchLoad(model,key){const request=getCurrentRequest();if(!request)return loadSingle(model,key);let models=requests.get(request);if(!models){models=new WeakMap;requests.set(request,models)}let state=models.get(model);if(!state){state={cache:new Map};models.set(model,state)}const existing=state.cache.get(key);if(existing)return existing.then();let batch=state.pending;if(!batch){batch={keys:[],resolvers:[]};state.pending=batch;const pending=batch,owner=state;queueMicrotask(()=>{drainBatch(model,owner,pending)})}const promise=new Promise((resolve,reject)=>{batch.keys.push(key);batch.resolvers.push({key,resolve,reject})});state.cache.set(key,promise);const cache=state.cache;promise.catch(()=>cache.delete(key));return promise.then()}
@@ -0,0 +1,68 @@
1
+ import type { Operator, SubqueryBuilder } from '@stacksjs/orm';
2
+ import type { RawBuilder } from '@stacksjs/database/runtime';
3
+ /**
4
+ * Generic QueryBuilder interface that mimics Laravel's query builder functionality
5
+ * for TypeScript ORM operations.
6
+ *
7
+ * @template T - The table type (e.g., PersonalAccessTokensTable)
8
+ * @template M - The model return type (e.g., AccessTokenModel)
9
+ * @template R - The JSON response type. Defaults to `unknown` so callers
10
+ * are forced to narrow before reading properties off the result —
11
+ * previously this defaulted to `any`, which let `.toJson().foo` compile
12
+ * even when `foo` didn't exist on the row (stacksjs/stacks#1876 O-6).
13
+ * @template Rel - The string-literal union of relation names this model can eager-load.
14
+ * Defaults to `string` for backwards compat when generators don't supply
15
+ * the union; downstream generated wrappers MUST narrow this to the
16
+ * actual relation names so `.with(['posts'])` autocompletes and typos
17
+ * become compile errors. Generator authors: derive the union from
18
+ * `definition.belongsTo | hasMany | hasOne | belongsToMany | hasOneThrough
19
+ * | hasManyThrough` joined into a literal-string union, and pass it as
20
+ * the fourth type arg when building the per-model wrapper.
21
+ */
22
+ export declare interface QueryBuilder<T, M, R = unknown, Rel extends string = string> {
23
+ where: <V = string>(column: keyof T, ...args: [V] | [Operator, V]) => M
24
+ find: (id: number) => Promise<M | undefined>
25
+ get: () => Promise<M[]>
26
+ orderBy: (column: keyof T, order: 'asc' | 'desc') => M
27
+ join: (table: string, firstCol: string, secondCol: string) => M
28
+ findOrFail: (id: number) => Promise<M>
29
+ whereIn: <V = number>(column: keyof T, values: V[]) => M
30
+ whereNotIn: <V = number>(column: keyof T, values: V[]) => M
31
+ whereNull: (column: keyof T) => M
32
+ whereNotNull: (column: keyof T) => M
33
+ whereBetween: <V = number>(column: keyof T, range: [V, V]) => M
34
+ whereRaw: (raw: RawBuilder<unknown>) => M
35
+ whereLike: (column: keyof T, value: string) => M
36
+ whereExists: (callback: (qb: any) => any) => M
37
+ when: (condition: boolean, callback: (query: M) => M) => M
38
+ select: (params: (keyof R)[] | RawBuilder<string>) => M
39
+ first: () => Promise<M | undefined>
40
+ firstOrFail: () => Promise<M>
41
+ take: (count: number) => M
42
+ skip: (count: number) => M
43
+ count: () => Promise<number>
44
+ exists: () => Promise<boolean>
45
+ with: (relations: Rel[]) => M
46
+ max: (field: keyof T) => Promise<number>
47
+ min: (field: keyof T) => Promise<number>
48
+ chunk: (size: number, callback: (models: M[]) => Promise<void>) => Promise<void>
49
+ avg: (field: keyof T) => Promise<number>
50
+ sum: (field: keyof T) => Promise<number>
51
+ orderByDesc: (column: keyof T) => M
52
+ orderByAsc: (column: keyof T) => M
53
+ pluck: <K extends keyof M>(field: K) => Promise<M[K][]>
54
+ groupBy: (column: keyof T) => M
55
+ having: <V = string>(column: keyof T, operator: Operator, value: V) => M
56
+ orWhere: (...conditions: [string, any][]) => M
57
+ has: (relation: string) => M
58
+ whereHas: (relation: string, callback: (query: SubqueryBuilder<keyof T>) => void) => M
59
+ doesntHave: (relation: string) => M
60
+ inRandomOrder: () => M
61
+ whereColumn: (first: keyof T, operator: Operator, second: keyof T) => M
62
+ distinct: (column: keyof R) => M
63
+ latest: () => Promise<M | undefined>
64
+ oldest: () => Promise<M | undefined>
65
+ last: () => Promise<M | undefined>
66
+ findMany: (ids: number[]) => Promise<M[]>
67
+ whereRef: (column: keyof T, ...args: string[]) => M
68
+ }
File without changes
package/dist/db.js ADDED
@@ -0,0 +1 @@
1
+ export{db}from"@stacksjs/database/runtime";
@@ -0,0 +1 @@
1
+ import{createModel,registerModel}from"@stacksjs/query-builder";import{log}from"@stacksjs/logging/runtime";import{snakeCase}from"@stacksjs/strings";import{AsyncLocalStorage}from"node:async_hooks";import{toCursorPaginator,toPaginator,toSimplePaginator}from"@stacksjs/pagination";import{enrichPaginatorUrls,resolveCursorArgs,resolvePageArgs}from"./paginator-request";import{hasWriteValidators,validateWriteBody}from"./auto-crud";let modelEventsModule;const eventSuppression=new AsyncLocalStorage;function eventsAreSuppressed(){return eventSuppression.getStore()?.suppressed===!0}export function withoutEvents(fn){return Promise.resolve(eventSuppression.run({suppressed:!0},fn))}const validationSuppression=new AsyncLocalStorage;function validationIsSuppressed(){return validationSuppression.getStore()?.suppressed===!0}export function withoutValidation(fn){return Promise.resolve(validationSuppression.run({suppressed:!0},fn))}export const MODEL_DEFINITION=Symbol.for("stacks.orm.modelDefinition");export class ModelValidationError extends Error{status=422;errors;constructor(modelName,errors){const fields=Object.keys(errors).join(", ");super(`${modelName} validation failed: ${fields}`);this.name="ModelValidationError";this.errors=errors}}const jsonParseFailureSeen=new Set,COLUMN_NAME_CACHE_LIMIT=4096,columnNameCache=Object.create(null);let columnNameCacheSize=0;function toColumnName(attribute){const cached=columnNameCache[attribute];if(cached!==void 0)return cached;const column=snakeCase(attribute);if(columnNameCacheSize<COLUMN_NAME_CACHE_LIMIT){columnNameCache[attribute]=column;columnNameCacheSize++}return column}function logJsonParseFailure(raw,err){const preview=typeof raw==="string"?raw.slice(0,80):String(raw),key=`${typeof raw}:${preview}`;if(jsonParseFailureSeen.has(key))return;jsonParseFailureSeen.add(key);const message=err instanceof Error?err.message:String(err);console.error(`[orm] JSON cast failed to parse value (returning null): ${message} - value preview: ${JSON.stringify(preview)}`)}const builtInCasters={string:{get:(v)=>v!=null?String(v):null,set:(v)=>v!=null?String(v):null},number:{get:(v)=>v!=null?Number(v):null,set:(v)=>v!=null?Number(v):null},integer:{get:(v)=>v!=null?Math.trunc(Number(v)):null,set:(v)=>v!=null?Math.trunc(Number(v)):null},float:{get:(v)=>v!=null?Number.parseFloat(String(v)):null,set:(v)=>v!=null?Number.parseFloat(String(v)):null},boolean:{get:(v)=>v===1||v==="1"||v===!0||v==="true",set:(v)=>v===!0||v===1||v==="1"||v==="true"?1:0},json:{get:(v)=>{if(v==null)return null;if(typeof v==="string")try{return JSON.parse(v)}catch(err){logJsonParseFailure(v,err);return null}return v},set:(v)=>{if(v==null)return null;return typeof v==="string"?v:JSON.stringify(v)}},datetime:{get:(v)=>v?new Date(v):null,set:(v)=>v instanceof Date?v.toISOString():v},date:{get:(v)=>v?new Date(v):null,set:(v)=>v instanceof Date?v.toISOString().split("T")[0]:v},array:{get:(v)=>{if(v==null)return[];if(Array.isArray(v))return v;if(typeof v==="string")try{return JSON.parse(v)}catch{return[]}return[]},set:(v)=>{if(v==null)return null;return Array.isArray(v)?JSON.stringify(v):v}}};function resolveCaster(cast){return typeof cast==="string"?builtInCasters[cast]:cast}const resolvedModelCastsByDefinition=new WeakMap;function resolveModelCasts(casts){const attrs=Object.keys(casts),cached=resolvedModelCastsByDefinition.get(casts);if(cached&&cached.length===attrs.length){let unchanged=!0;for(let index=0;index<attrs.length;index++){const attr=attrs[index];if(cached[index]?.attr!==attr||cached[index]?.definition!==casts[attr]){unchanged=!1;break}}if(unchanged)return cached}const resolved=attrs.map((attr)=>{const definition=casts[attr];return{attr,definition,caster:resolveCaster(definition)}});resolvedModelCastsByDefinition.set(casts,resolved);return resolved}function castAttributes(row,casts,direction){if(!row||typeof row!=="object")return row;const result={...row};for(const{attr,caster}of resolveModelCasts(casts))if(attr in result)result[attr]=caster[direction].call(caster,result[attr]);return result}function isModelInstanceInternalKey(key){return key==="_attributes"||key==="_original"||key==="_definition"||key==="_hasSaved"||key==="_relations"}const STACKS_PROXY_TAG=Symbol.for("stacks.modelInstanceProxy");let defaultModelInstanceProxyHandler;const castModelInstanceProxyHandlers=new WeakMap,TRAIT_INSTANCE_METHOD_BINDINGS={tags:{bag:"_taggable",mode:"id"},tagCount:{bag:"_taggable",mode:"id"},addTag:{bag:"_taggable",mode:"id"},activeTags:{bag:"_taggable",mode:"id"},inactiveTags:{bag:"_taggable",mode:"id"},removeTag:{bag:"_taggable",mode:"id"},categories:{bag:"_categorizable",mode:"id"},categoryCount:{bag:"_categorizable",mode:"id"},addCategory:{bag:"_categorizable",mode:"id"},activeCategories:{bag:"_categorizable",mode:"id"},inactiveCategories:{bag:"_categorizable",mode:"id"},removeCategory:{bag:"_categorizable",mode:"id"},comments:{bag:"_commentable",mode:"id"},commentCount:{bag:"_commentable",mode:"id"},addComment:{bag:"_commentable",mode:"id"},approvedComments:{bag:"_commentable",mode:"id"},pendingComments:{bag:"_commentable",mode:"id"},rejectedComments:{bag:"_commentable",mode:"id"},likes:{bag:"_likeable",mode:"id"},likeCount:{bag:"_likeable",mode:"id"},like:{bag:"_likeable",mode:"id"},unlike:{bag:"_likeable",mode:"id"},isLiked:{bag:"_likeable",mode:"id"},...Object.fromEntries(BILLABLE_INSTANCE_METHODS.map((name)=>[name,{bag:"_billable",mode:"model"}])),generateTwoFactorForModel:{bag:"_twoFactor",mode:"model"},verifyTwoFactorCode:{bag:"_twoFactor",mode:"model"}};function resolveRelationPath(relations,path){if(!relations||!path)return null;const segments=path.split(".");let current=relations;for(const segment of segments){if(current==null||typeof current!=="object")return null;const obj=current;if("_attributes"in obj&&obj._attributes&&typeof obj._attributes==="object"){const attrs=obj._attributes;if(segment in attrs){current=attrs[segment];continue}}if(segment in obj){current=obj[segment];continue}return null}return current??null}function keepWrapped(result,raw,wrapped){if(result&&typeof result.then==="function")return result.then((value)=>value===raw?wrapped:value);return result===raw?wrapped:result}function wrapModelInstance(instance,casts){if(!instance||typeof instance!=="object")return instance;const candidate=instance;if(candidate[STACKS_PROXY_TAG])return instance;const attrs=candidate._attributes;if(!attrs||typeof attrs!=="object")return instance;if(casts){for(const{attr,caster}of resolveModelCasts(casts))if(Object.hasOwn(attrs,attr))attrs[attr]=caster.get.call(caster,attrs[attr])}let handler=casts?castModelInstanceProxyHandlers.get(casts):defaultModelInstanceProxyHandler;if(!handler){handler={get(rawTarget,prop,recv){const target=rawTarget;if(prop===STACKS_PROXY_TAG)return!0;if(prop==="saveAsync")return async function(){const def=target._definition,setters=def?.set;if(setters&&typeof target.isDirty==="function"){for(const[key,fn]of Object.entries(setters)){if(typeof fn!=="function")continue;if(!target.isDirty(key))continue;const result=fn(target._attributes),value=result&&typeof result.then==="function"?await result:result;target._attributes[key]=value}const original=def.set;def.set={};try{return keepWrapped(target.save(),rawTarget,recv)}finally{def.set=original}}return keepWrapped(target.save(),rawTarget,recv)};if(prop==="save")return function(){const def=target._definition,setters=def?.set;if(setters&&Object.keys(setters).length>0&&typeof target.isDirty==="function"){for(const[key,fn]of Object.entries(setters)){if(typeof fn!=="function")continue;if(!target.isDirty(key))continue;const result=fn(target._attributes);if(result&&typeof result.then==="function")throw Error(`Setter for "${key}" returned a Promise - use \`saveAsync()\` instead of \`save()\` when a model has async setters.`);target._attributes[key]=result}const original=def.set;def.set={};try{return keepWrapped(target.save(),rawTarget,recv)}finally{def.set=original}}return keepWrapped(target.save(),rawTarget,recv)};if(prop==="updateAsync")return async function(data){if(data&&typeof data==="object"&&!Array.isArray(data))for(const[k,v]of Object.entries(data))recv[k]=v;return recv.saveAsync()};if(prop==="saveQuietly")return function(){return withoutEvents(()=>target.save())};if(prop==="saveAsyncQuietly")return function(){return withoutEvents(()=>recv.saveAsync())};if(prop==="updateQuietly")return function(data){return withoutEvents(()=>target.update(data))};if(prop==="updateAsyncQuietly")return function(data){return withoutEvents(()=>recv.updateAsync(data))};if(prop==="deleteQuietly")return function(){return withoutEvents(()=>target.delete())};if(prop==="toSearchableObject")return function toSearchableObject(){const search=target._definition?.traits?.useSearch;if(!search)return null;const attrs=target._attributes;if(!attrs)return null;if(search===!0){const doc={...attrs};if(attrs.id!=null)doc.id=String(attrs.id);return doc}const doc={},keys=search.displayable?.length?search.displayable:[...search.searchable??[],...search.filterable??[],...search.sortable??[],"id"],denormalize=search.denormalize??{},relations=target._relations;for(const key of keys){const snake=snakeCase(key),own=attrs[snake]??attrs[key];if(own!==void 0){doc[snake]=own;continue}const path=denormalize[key]??denormalize[snake];if(path){doc[snake]=resolveRelationPath(relations,path);continue}doc[snake]=void 0}if(attrs.id!=null)doc.id=String(attrs.id);return doc};if(typeof prop==="string"&&!isModelInstanceInternalKey(prop)){const a=target._attributes;if(a&&Object.hasOwn(a,prop))return a[prop];const rels=target._relations;if(rels&&Object.hasOwn(rels,prop)){const related=rels[prop];if(Array.isArray(related))return related.map((x)=>wrapModelInstance(x,casts));return related&&typeof related==="object"?wrapModelInstance(related,casts):related}const binding=TRAIT_INSTANCE_METHOD_BINDINGS[prop];if(binding){const fn=target._definition?.__traitMethods?.[binding.bag]?.[prop];if(typeof fn==="function")return binding.mode==="model"?(...args)=>fn(recv,...args):(...args)=>fn(target.id,...args)}if(a){const column=toColumnName(prop);if(column!==prop&&Object.hasOwn(a,column))return a[column]}}const v=Reflect.get(target,prop,target);return typeof v==="function"?v.bind(target):v},set(rawTarget,prop,value,recv){const target=rawTarget;if(typeof prop==="string"&&!isModelInstanceInternalKey(prop)){const{_attributes:a,set:setter}=target;if(a&&Object.hasOwn(a,prop)){if(typeof setter==="function")setter.call(target,prop,value);else a[prop]=value;return!0}if(a){const column=toColumnName(prop);if(column!==prop&&Object.hasOwn(a,column)){if(typeof setter==="function")setter.call(target,column,value);else a[column]=value;return!0}}if(a&&!(prop in target)){if(typeof setter==="function")setter.call(target,prop,value);else a[prop]=value;return!0}}return Reflect.set(target,prop,value,recv)},has(rawTarget,prop){const target=rawTarget;if(typeof prop==="string"&&!isModelInstanceInternalKey(prop)){const a=target._attributes;if(a&&Object.hasOwn(a,prop))return!0;const rels=target._relations;if(rels&&Object.hasOwn(rels,prop))return!0;if(a){const column=toColumnName(prop);if(column!==prop&&Object.hasOwn(a,column))return!0}}return Reflect.has(target,prop)},deleteProperty(rawTarget,prop){const target=rawTarget;if(typeof prop==="string"&&!isModelInstanceInternalKey(prop)){const a=target._attributes;if(a&&Object.hasOwn(a,prop)){delete a[prop];return!0}}return Reflect.deleteProperty(target,prop)},ownKeys(rawTarget){const a=rawTarget._attributes;return a?Object.keys(a):[]},getOwnPropertyDescriptor(rawTarget,prop){const target=rawTarget;if(typeof prop==="string"){const a=target._attributes;if(a&&Object.hasOwn(a,prop))return{configurable:!0,enumerable:!0,value:a[prop],writable:!0}}return Object.getOwnPropertyDescriptor(target,prop)}};if(casts)castModelInstanceProxyHandlers.set(casts,handler);else defaultModelInstanceProxyHandler=handler}return new Proxy(instance,handler)}const QB_TERMINATORS=Object.assign(Object.create(null),{get:!0,first:!0,last:!0,firstOrFail:!0,find:!0,findOrFail:!0,all:!0,paginate:!0,simplePaginate:!0,cursorPaginate:!0}),PAGINATE_ADAPTERS={paginate:toPaginator,simplePaginate:toSimplePaginator,cursorPaginate:toCursorPaginator},STACKS_QB_PROXY_TAG=Symbol.for("stacks.queryBuilderProxy");function finalizeQueryResult(result,propName,casts){if(QB_TERMINATORS[propName]){if(Array.isArray(result))return result.map((item)=>wrapModelInstance(item,casts));const adapter=PAGINATE_ADAPTERS[propName];if(adapter&&result&&Array.isArray(result.data)){const wrappedData=result.data.map((item)=>wrapModelInstance(item,casts)),canonical=adapter({...result,data:wrappedData});return enrichPaginatorUrls(canonical)}if(result&&Array.isArray(result.data))return{...result,data:result.data.map((item)=>wrapModelInstance(item,casts))};return wrapModelInstance(result,casts)}if(result&&typeof result==="object"&&typeof result.get==="function")return wrapQueryBuilder(result,casts);return result}function wrapQueryBuilder(qb,casts){if(!qb||typeof qb!=="object")return qb;if(qb[STACKS_QB_PROXY_TAG])return qb;return new Proxy(qb,{get(target,prop,recv){if(prop===STACKS_QB_PROXY_TAG)return!0;const v=Reflect.get(target,prop,recv);if(typeof v!=="function")return v;const propName=String(prop);return function(...args){let callArgs=args;if(propName==="paginate"||propName==="simplePaginate"){const{perPage,page}=resolvePageArgs(args[0],args[1]);callArgs=[perPage,page]}else if(propName==="cursorPaginate"){const{perPage,cursor}=resolveCursorArgs(args[0],args[1]);callArgs=[perPage,cursor,args[2],args[3]]}const result=v.apply(target,callArgs);if(!QB_TERMINATORS[propName]&&result===target)return recv;if(result&&typeof result.then==="function")return result.then((resolved)=>finalizeQueryResult(resolved,propName,casts));return finalizeQueryResult(result,propName,casts)}}})}function wrapReadsWithProxy(baseModel,casts){const applyReadResult=(result)=>Array.isArray(result)?result.map((item)=>wrapModelInstance(item,casts)):wrapModelInstance(result,casts),directReads=["find","first","last","all","firstOrFail","findOrFail","findMany"];for(const method of directReads){const original=baseModel[method];if(typeof original!=="function")continue;baseModel[method]=function(...args){const result=original.apply(this,args);if(result&&typeof result.then==="function")return result.then(applyReadResult);return applyReadResult(result)}}const writeReturningInstance=["create","createMany","firstOrCreate","updateOrCreate","make"];for(const method of writeReturningInstance){const original=baseModel[method];if(typeof original!=="function")continue;baseModel[method]=function(...args){const result=original.apply(this,args);if(result&&typeof result.then==="function")return result.then(applyReadResult);return applyReadResult(result)}}const queryBuilderEntrypoints=["query","where","orWhere","whereIn","whereNotIn","whereNull","whereNotNull","whereLike","whereBetween","whereNotBetween","orderBy","orderByDesc","select","with","limit","take","skip","latest","oldest"];for(const method of queryBuilderEntrypoints){const original=baseModel[method];if(typeof original!=="function")continue;baseModel[method]=function(...args){const qb=original.apply(this,args);return wrapQueryBuilder(qb,casts)}}}export class ModelNotFoundError extends Error{model;id;constructor(model,id){super(id!=null?`[ORM] ${model} not found for id=${String(id)}`:`[ORM] No matching ${model} row`);this.name="ModelNotFoundError";this.model=model;this.id=id}}export class MassAssignmentException extends Error{model;attribute;reason;constructor(model,attribute,reason){const why=reason==="guarded"?`'${attribute}' is marked guarded`:`'${attribute}' is not in the fillable allowlist`;super(`[ORM] Mass assignment to ${model} forbidden: ${why}. Use ${model}.forceCreate(...) / .forceUpdate(...) to bypass.`);this.name="MassAssignmentException";this.model=model;this.attribute=attribute;this.reason=reason}}const MASS_ASSIGNMENT_SYSTEM_COLUMNS=new Set(["id","created_at","updated_at","deleted_at","uuid"]),massAssignmentRuleSets=new WeakMap;function resolveMassAssignmentRuleSet(attrs){const cached=massAssignmentRuleSets.get(attrs);if(cached)return cached;const declared=new Set,fillable=new Set,guarded=new Set;for(const[key,attribute]of Object.entries(attrs)){const column=toColumnName(key);declared.add(column);if(attribute?.fillable===!0)fillable.add(column);if(attribute?.guarded===!0)guarded.add(column)}const rules={allowed:fillable.size>0?fillable:declared,guarded};massAssignmentRuleSets.set(attrs,rules);return rules}function applyMassAssignmentRules(definition,data,options={}){if(options.force)return data;if(!data||typeof data!=="object"||Array.isArray(data))return data;const attrs=definition.attributes;if(!attrs)return data;const{allowed,guarded}=resolveMassAssignmentRuleSet(attrs);for(const key in data){if(!Object.hasOwn(data,key))continue;const col=toColumnName(key);if(MASS_ASSIGNMENT_SYSTEM_COLUMNS.has(col))continue;if(guarded.has(col))throw new MassAssignmentException(definition.name,key,"guarded");if(col.endsWith("_id"))continue;if(!allowed.has(col))throw new MassAssignmentException(definition.name,key,"not-fillable")}return data}async function applyDefinedSetters(definition,data){const setters=definition.set;if(!setters||typeof setters!=="object")return data;const out={...data};for(const[key,fn]of Object.entries(setters)){if(typeof fn!=="function"||!(key in out))continue;try{out[key]=await fn(out)}catch(err){log.error(`[orm] ${definition.name}.set.${key} threw - skipping setter`,err)}}return out}function addStaticHelpers(baseModel,definition){const pk=definition.primaryKey||"id",unwrappedCreate=baseModel.create;function getWhere(method){const w=baseModel.where;if(typeof w!=="function")throw Error(`[ORM] ${definition.name}.${method} needs a working where(): the underlying query builder did not expose one`);return w}function getFind(method){const f=baseModel.find;if(typeof f!=="function")throw Error(`[ORM] ${definition.name}.${method} needs a working find(): the underlying query builder did not expose one`);return f}function getCreate(method){const c=baseModel.create;if(typeof c!=="function")throw Error(`[ORM] ${definition.name}.${method} cannot create: the underlying query builder did not expose create()`);return c}baseModel.update=async function(id,data){if(id==null)throw Error(`[ORM] ${definition.name}.update requires an id as the first argument`);if(!data||typeof data!=="object"||Array.isArray(data))throw Error(`[ORM] ${definition.name}.update requires a data object as the second argument`);if(Object.keys(data).length===0){log.debug(`[orm] ${definition.name}.update called with empty data - short-circuiting and returning current row`);const f=baseModel.find;return typeof f==="function"?await f.call(baseModel,id):null}applyMassAssignmentRules(definition,data);const finalData=await applyDefinedSetters(definition,data);await getWhere("update").call(baseModel,pk,id).update(finalData);const f=baseModel.find;return typeof f==="function"?await f.call(baseModel,id):null};if(typeof baseModel.forceUpdate!=="function")baseModel.forceUpdate=async function(id,data){if(id==null)throw Error(`[ORM] ${definition.name}.forceUpdate requires an id as the first argument`);if(!data||typeof data!=="object"||Array.isArray(data))throw Error(`[ORM] ${definition.name}.forceUpdate requires a data object as the second argument`);const finalData=await applyDefinedSetters(definition,data);await getWhere("forceUpdate").call(baseModel,pk,id).update(finalData);const f=baseModel.find;return typeof f==="function"?await f.call(baseModel,id):null};if(typeof baseModel.forceCreate!=="function")baseModel.forceCreate=async function(data){if(!data||typeof data!=="object"||Array.isArray(data))throw Error(`[ORM] ${definition.name}.forceCreate requires a data object`);const make=baseModel.make;if(typeof make==="function"){const instance=await make.call(baseModel);if(instance&&typeof instance.forceFill==="function"&&typeof instance.save==="function"){instance.forceFill(data);const saved=await instance.save();return saved&&typeof saved==="object"&&saved!==instance&&!saved[STACKS_PROXY_TAG]?instance:saved??instance}}if(typeof unwrappedCreate!=="function")throw Error(`[ORM] ${definition.name}.forceCreate cannot create: the underlying query builder did not expose create()`);return await unwrappedCreate.call(baseModel,data)};baseModel.delete=async function(id){if(id==null)throw Error(`[ORM] ${definition.name}.delete requires an id as the first argument`);if(!await getFind("delete").call(baseModel,id))return!1;await getWhere("delete").call(baseModel,pk,id).delete();return!0};if(typeof baseModel.findOrFail!=="function")baseModel.findOrFail=async function(id){const row=await getFind("findOrFail").call(baseModel,id);if(row==null)throw new ModelNotFoundError(definition.name,id);return row};if(typeof baseModel.exists!=="function")baseModel.exists=async function(id){if(id==null)return!1;const q=getWhere("exists").call(baseModel,pk,id);if(typeof q.count==="function"){const n=await q.count();return Number(n)>0}const f=baseModel.find;return typeof f==="function"?await f.call(baseModel,id)!=null:!1};if(typeof baseModel.firstOrCreate!=="function")baseModel.firstOrCreate=async function(attrs,defaults={}){if(!attrs||typeof attrs!=="object"||Array.isArray(attrs))throw Error(`[ORM] ${definition.name}.firstOrCreate requires a where-attrs object`);const q=getWhere("firstOrCreate").call(baseModel,attrs),existing=typeof q.first==="function"?await q.first():await q;if(existing)return existing;const payload={...attrs,...defaults};applyMassAssignmentRules(definition,payload);return await getCreate("firstOrCreate").call(baseModel,payload)};if(typeof baseModel.updateOrCreate!=="function")baseModel.updateOrCreate=async function(attrs,values){if(!attrs||typeof attrs!=="object"||Array.isArray(attrs))throw Error(`[ORM] ${definition.name}.updateOrCreate requires a where-attrs object`);if(!values||typeof values!=="object"||Array.isArray(values))throw Error(`[ORM] ${definition.name}.updateOrCreate requires a values object`);const q=getWhere("updateOrCreate").call(baseModel,attrs),existing=typeof q.first==="function"?await q.first():await q;if(existing){applyMassAssignmentRules(definition,values);const id=existing[pk];await getWhere("updateOrCreate").call(baseModel,pk,id).update(values);const f=baseModel.find;return typeof f==="function"?await f.call(baseModel,id):{...existing,...values}}const payload={...attrs,...values};applyMassAssignmentRules(definition,payload);return await getCreate("updateOrCreate").call(baseModel,payload)};if(typeof baseModel.count!=="function")baseModel.count=async function(){const q=baseModel;if(typeof q.query==="function"){const builder=q.query();if(typeof builder?.count==="function"){const n=await builder.count();return Number(n)||0}}const{all,get}=baseModel;if(typeof all==="function")return(await all.call(baseModel)||[]).length;if(typeof get==="function")return(await get.call(baseModel)||[]).length;return 0};if(typeof baseModel.pluck!=="function")baseModel.pluck=async function(column){if(!column||typeof column!=="string")throw Error(`[ORM] ${definition.name}.pluck requires a column name`);const all=baseModel.all;return((typeof all==="function"?await all.call(baseModel):[])||[]).map((r)=>r?.[column])};if(typeof baseModel.whereIn!=="function")baseModel.whereIn=async function(column,values){if(!column||typeof column!=="string")throw Error(`[ORM] ${definition.name}.whereIn requires a column name`);if(!Array.isArray(values))throw Error(`[ORM] ${definition.name}.whereIn requires an array of values`);if(values.length===0)return[];const w=baseModel.where;if(typeof w!=="function")return[];const q=w.call(baseModel,column,"in",values);if(q&&typeof q.get==="function")return await q.get();return await q};const orderHelpers=[["latest","desc"],["oldest","asc"]];for(const[name,dir]of orderHelpers)if(typeof baseModel[name]!=="function")baseModel[name]=async function(column="created_at"){const q=baseModel,builder=typeof q.query==="function"?q.query():q;if(typeof builder?.orderBy==="function"){const ordered=builder.orderBy(column,dir);if(typeof ordered.first==="function")return await ordered.first();if(typeof ordered.get==="function")return(await ordered.get())?.[0]??null}const all=baseModel.all;if(typeof all!=="function")return null;return[...await all.call(baseModel)||[]].sort((a,b)=>{const av=a?.[column],bv=b?.[column];if(av===bv)return 0;return(av>bv?1:-1)*(dir==="desc"?-1:1)})[0]??null};const searchConfig=resolveSearchConfig(definition.traits?.useSearch,definition);if(searchConfig){const indexName=searchConfig.index??definition.table??snakeCase(`${definition.name}s`);if(typeof baseModel.search!=="function")baseModel.search=function(query,params){return createSearchQueryBuilder(baseModel,indexName,query,params,searchConfig.searchable)};if(typeof baseModel.makeAllSearchable!=="function")baseModel.makeAllSearchable=async function(chunkSize=500){const all=baseModel.all;if(typeof all!=="function")throw Error(`[ORM] ${definition.name}.makeAllSearchable cannot run: the underlying query builder did not expose all()`);const{useSearchEngine}=await import("@stacksjs/search-engine"),engine=useSearchEngine();let total=0;const query=baseModel.query;if(typeof query==="function")try{const q=query.call(baseModel);if(q&&typeof q.chunk==="function"){await q.chunk(chunkSize,async(rows)=>{const docs=await projectDocumentsFromTrait(rows,searchConfig);if(docs.length>0)await engine.addDocuments(indexName,docs);total+=docs.length});return total}}catch{}const rows=await all.call(baseModel)||[],docs=await projectDocumentsFromTrait(rows,searchConfig);if(docs.length>0)await engine.addDocuments(indexName,docs);return docs.length};if(typeof baseModel.removeAllFromSearch!=="function")baseModel.removeAllFromSearch=async function(){const{searchEngineReady}=await import("@stacksjs/search-engine");await(await searchEngineReady()).deleteAllDocuments(indexName)}}}export async function projectDocumentsFromTrait(models,config){if(models.length===0)return[];if(typeof config.shapeMany==="function"){const docs=await config.shapeMany(models);if(!Array.isArray(docs)||docs.length!==models.length)throw TypeError(`[orm/search] shapeMany returned ${Array.isArray(docs)?docs.length:typeof docs} documents for ${models.length} rows. It must return one document per row, in order, or documents are indexed under the wrong ids.`);return docs.filter(Boolean)}return(await Promise.all(models.map((model)=>projectDocumentFromTrait(model,config)))).filter(Boolean)}function hiddenAttributeNames(definition){const hidden=new Set,attributes=definition.attributes??{};for(const[name,attribute]of Object.entries(attributes))if(attribute?.hidden)hidden.add(name);return hidden}function withoutHidden(document,hidden){if(!document||hidden.size===0)return document;const safe={};for(const[key,value]of Object.entries(document))if(!hidden.has(key))safe[key]=value;return safe}async function projectDocumentFromTrait(model,config){if(typeof config.shape==="function")try{return await config.shape(model)}catch{return}const fromHook=model?.toSearchableObject?.();if(fromHook!==void 0)return fromHook;return withoutHidden(model?._attributes??(typeof model?.toJSON==="function"?model.toJSON():null),config.hidden??new Set)}function createSearchQueryBuilder(_baseModel,indexName,query,initialParams,searchableFields){const defaultQueryBy=searchableFields&&searchableFields.length>0?{query_by:[...searchableFields].join(",")}:{};let params={q:query,...defaultQueryBy,...initialParams??{}};const builder={with(extra){params={...params,...extra};return builder},where(filter){params={...params,filter};return builder},async get(){const{useSearchEngine}=await import("@stacksjs/search-engine");return(await useSearchEngine().search(indexName,params))?.hits??[]},async paginate(perPage,page=1){if(!Number.isInteger(perPage)||perPage<1)throw TypeError(`[orm/search] paginate() needs a positive integer page size, got ${perPage}`);if(!Number.isInteger(page)||page<1)throw TypeError(`[orm/search] paginate() pages are 1-based, got ${page}`);const{useSearchEngine}=await import("@stacksjs/search-engine"),result=await useSearchEngine().search(indexName,{...params,limit:perPage,offset:(page-1)*perPage});return{hits:result.hits??[],total:Number(result.estimatedTotalHits??result.nbHits??result.total??0),page,perPage}},async raw(){const{useSearchEngine}=await import("@stacksjs/search-engine");return await useSearchEngine().search(indexName,params)}};return builder}function encryptedAliases(key){const snake=snakeCase(key);return snake===key?[key]:[key,snake]}async function decryptAttrsInPlace(row,encryptedKeys){if(!row||typeof row!=="object")return;const bag=row._attributes??row;for(const key of encryptedKeys)for(const alias of encryptedAliases(key)){if(!(alias in bag))continue;const value=bag[alias];if(!isEncrypted(value))continue;bag[alias]=await decryptValue(value)}}function wrapReadsWithEncryption(baseModel,encryptedKeys){if(encryptedKeys.length===0)return;const decryptResult=async(r)=>{if(Array.isArray(r)){for(const item of r)await decryptAttrsInPlace(item,encryptedKeys);return r}if(r&&typeof r==="object"&&Array.isArray(r.data)){for(const item of r.data)await decryptAttrsInPlace(item,encryptedKeys);return r}if(r)await decryptAttrsInPlace(r,encryptedKeys);return r},directReads=["find","first","last","all","firstOrFail","findOrFail","findMany"];for(const method of directReads){const original=baseModel[method];if(typeof original!=="function")continue;baseModel[method]=async function(...args){const result=await original.apply(this,args);return await decryptResult(result)}}const writeReturningInstance=["create","createMany","firstOrCreate","updateOrCreate","make"];for(const method of writeReturningInstance){const original=baseModel[method];if(typeof original!=="function")continue;baseModel[method]=async function(...args){const result=await original.apply(this,args);return await decryptResult(result)}}for(const method of["update","forceUpdate"]){const original=baseModel[method];if(typeof original!=="function")continue;baseModel[method]=async function(id,data){const result=await original.call(this,id,data);return await decryptResult(result)}}const terminals=["first","firstOrFail","get","all","find","findOrFail","last","paginate","simplePaginate"],decorateBuilder=(builder)=>{if(!builder||typeof builder!=="object")return builder;const target=builder;if(target.__stacksEncryptedReads)return builder;for(const method of terminals){const original=target[method];if(typeof original!=="function")continue;target[method]=async function(...args){return await decryptResult(await original.apply(this,args))}}for(const method of["where","whereIn","whereNull","whereNotNull","orWhere","orderBy","orderByDesc","limit","offset","with"]){const original=target[method];if(typeof original!=="function")continue;target[method]=function(...args){return decorateBuilder(original.apply(this,args))}}Object.defineProperty(target,"__stacksEncryptedReads",{value:!0,enumerable:!1});return builder};for(const entry of["where","whereIn","whereNull","whereNotNull","orderBy","orderByDesc","limit","query"]){const original=baseModel[entry];if(typeof original!=="function")continue;baseModel[entry]=function(...args){return decorateBuilder(original.apply(this,args))}}}function wrapWritesWithEncryption(baseModel,encryptedKeys){if(encryptedKeys.length===0)return;const encryptArg=async(data)=>{if(!data||typeof data!=="object"||Array.isArray(data))return data;const out={...data};for(const key of encryptedKeys)for(const alias of encryptedAliases(key))if(alias in out)out[alias]=await encryptValue(out[alias]);return out},writeMethods=["create","firstOrCreate","updateOrCreate","forceCreate"],originalCreateMany=baseModel.createMany;if(typeof originalCreateMany==="function")baseModel.createMany=async function(items,...rest){const encrypted=Array.isArray(items)?await Promise.all(items.map(encryptArg)):items;return await originalCreateMany.call(this,encrypted,...rest)};for(const method of writeMethods){const original=baseModel[method];if(typeof original!=="function")continue;baseModel[method]=async function(...args){args[0]=await encryptArg(args[0]);if(args[1])args[1]=await encryptArg(args[1]);return await original.apply(this,args)}}for(const method of["update","forceUpdate"]){const original=baseModel[method];if(typeof original!=="function")continue;baseModel[method]=async function(id,data){const enc=await encryptArg(data);return await original.call(this,id,enc)}}}function wrapWritesWithMassAssignment(baseModel,definition){const origCreate=baseModel.create;if(typeof origCreate==="function")baseModel.create=async function(data,...rest){applyMassAssignmentRules(definition,data);const finalData=await applyDefinedSetters(definition,data);return await origCreate.call(this,finalData,...rest)};const origCreateMany=baseModel.createMany;if(typeof origCreateMany==="function")baseModel.createMany=async function(items,...rest){const finalItems=[];for(const data of items){applyMassAssignmentRules(definition,data);finalItems.push(await applyDefinedSetters(definition,data))}return await origCreateMany.call(this,finalItems,...rest)};for(const method of["firstOrCreate","updateOrCreate"]){const orig=baseModel[method];if(typeof orig!=="function")continue}}function wrapWritesWithValidation(baseModel,definition){if(!hasWriteValidators(definition))return;const modelName=String(definition?.name??baseModel.name??"Model"),check=(data,hook)=>{if(validationIsSuppressed())return;if(!data||typeof data!=="object"||Array.isArray(data))return;const result=validateWriteBody(data,definition,hook);if(!result.valid)throw new ModelValidationError(modelName,result.errors)},origCreate=baseModel.create;if(typeof origCreate==="function")baseModel.create=async function(data,...rest){check(data,"creating");return await origCreate.call(this,data,...rest)};const origCreateMany=baseModel.createMany;if(typeof origCreateMany==="function")baseModel.createMany=async function(items,...rest){if(Array.isArray(items))for(const data of items)check(data,"creating");return await origCreateMany.call(this,items,...rest)};const origUpdate=baseModel.update;if(typeof origUpdate==="function")baseModel.update=async function(id,data,...rest){check(data,"updating");return await origUpdate.call(this,id,data,...rest)}}function wrapQueryMethodsWithCasts(baseModel,casts){const writeMethods=["create","firstOrCreate","updateOrCreate"],originalCreateMany=baseModel.createMany;if(typeof originalCreateMany==="function")baseModel.createMany=async function(items,...rest){const cast=Array.isArray(items)?items.map((item)=>item&&typeof item==="object"&&!Array.isArray(item)?castAttributes(item,casts,"set"):item):items;return await originalCreateMany.call(this,cast,...rest)};for(const method of writeMethods){const original=baseModel[method];if(typeof original==="function")baseModel[method]=async function(...args){log.debug(`[orm] ${method.charAt(0).toUpperCase()+method.slice(1)} ${String(baseModel.name||"unknown")}`,args[0]);if(args[0]&&typeof args[0]==="object"&&!Array.isArray(args[0]))args[0]=castAttributes(args[0],casts,"set");return await original.apply(this,args)}}const origUpdate=baseModel.update;if(typeof origUpdate==="function")baseModel.update=async function(id,data){const cast=data&&typeof data==="object"&&!Array.isArray(data)?castAttributes(data,casts,"set"):data;return await origUpdate.call(this,id,cast)}}import{createTaggableMethods}from"./traits/taggable";import{createCategorizableMethods}from"./traits/categorizable";import{createCommentableMethods}from"./traits/commentable";import{BILLABLE_INSTANCE_METHODS,createBillableMethods}from"./traits/billable";import{createLikeableMethods}from"./traits/likeable";import{createTwoFactorMethods}from"./traits/two-factor";import{createSoftDeleteMethods,resolveSoftDeleteOptions,cascadeSoftDelete}from"./traits/soft-deletes";import{applyAudit,resolveAuditOptions}from"./traits/audit";import{applyActivityLog,resolveActivityLogOptions}from"./traits/activity-log";import{collectEncryptedAttributes,decryptValue,encryptValue,isEncrypted}from"./utils/encrypted";const KNOWN_TRAITS=new Set(["useUuid","useTimestamps","timestampable","useSoftDeletes","softDeletable","useApi","useAuth","authenticatable","useSearch","searchable","useSeeder","seedable","useSocials","billable","observe","useAudit","useActivityLog","prunable","sharding","likeable","taggable","categorizable","commentable","commentables","broadcastOn","broadcastWith","gdpr"]);export function defineModel(definition){log.debug(`[orm] Defining model: ${definition.name} (table: ${definition.table})`);for(const trait of Object.keys(definition.traits??{}))if(!KNOWN_TRAITS.has(trait))log.warn(`[orm] Model '${definition.name}' declares an unknown trait '${trait}'. It does nothing. Known traits: ${[...KNOWN_TRAITS].sort().join(", ")}.`);const observeHooks=buildEventHooks(definition),searchHooks=buildSearchHooks(definition),hooks=mergeModelHooks(observeHooks,searchHooks),traitMethods=buildTraitMethods(definition);definition.__traitMethods=traitMethods;const authenticatable=definition.traits;if((authenticatable?.useAuth||authenticatable?.authenticatable)&&definition.name!=="PersonalAccessToken"){const declared=definition.morphMany;if(!declared||!("tokenable"in declared))definition.morphMany={...declared??{},tokenable:"PersonalAccessToken"}}const defWithHooks=hooks?{...definition,hooks:{...definition.hooks,...hooks}}:definition,baseModel=createModel(defWithHooks);wrapReadsWithProxy(baseModel,definition.casts);addStaticHelpers(baseModel,defWithHooks);wrapWritesWithMassAssignment(baseModel,defWithHooks);if(definition.casts&&Object.keys(definition.casts).length>0)wrapQueryMethodsWithCasts(baseModel,definition.casts);const encryptedKeys=collectEncryptedAttributes(definition);if(encryptedKeys.length>0){wrapWritesWithEncryption(baseModel,encryptedKeys);wrapReadsWithEncryption(baseModel,encryptedKeys)}const softDeleteFlag=definition.traits?.useSoftDeletes;if(softDeleteFlag)applySoftDeletes(baseModel,defWithHooks,softDeleteFlag);const useAuditDecl=definition.traits?.useAudit;if(useAuditDecl){const auditOpts=resolveAuditOptions(useAuditDecl);applyAudit(baseModel,definition.name,definition.primaryKey||"id",auditOpts)}const activityOptions=resolveActivityLogOptions(definition.traits?.useActivityLog);if(activityOptions)applyActivityLog(baseModel,definition.name,definition.primaryKey||"id",activityOptions,[...hiddenAttributeNames(definition)]);wrapWritesWithValidation(baseModel,defWithHooks);const withHelpers=baseModel;withHelpers.withoutValidation=function(fn){return withoutValidation(fn)};withHelpers.withoutEvents=function(fn){return withoutEvents(fn)};for(const method of["create","createMany","update","firstOrCreate","updateOrCreate","forceCreate","forceUpdate","delete","forceDelete","softDelete","restore"]){const orig=baseModel[method];if(typeof orig!=="function")continue;const quietName=`${method}Quietly`;if(typeof baseModel[quietName]==="function")continue;baseModel[quietName]=function(...args){return withoutEvents(()=>orig.apply(baseModel,args))}}const finalModel=Object.assign(baseModel,traitMethods,{...definition,getDefinition:()=>definition,_isStacksModel:!0});registerModel(definition.name,finalModel);Object.defineProperty(finalModel,MODEL_DEFINITION,{value:definition,enumerable:!1,configurable:!0});return finalModel}function buildEventHooks(definition){const observe=definition.traits?.observe;if(!observe)return;const modelName=definition.name.toLowerCase(),toEventPayload=(model)=>{const attributes=model?.attributes;if(!attributes||typeof attributes!=="object")return model;const pk=definition.primaryKey||"id",payload={...attributes};if(payload[pk]===void 0&&model[pk]!==void 0)payload[pk]=model[pk];return payload},dispatchEvent=async(event,data)=>{if(eventsAreSuppressed())return;try{const{dispatch}=modelEventsModule??=await import("@stacksjs/events");await dispatch(event,data)}catch(err){if(err&&typeof err==="object"&&"code"in err&&err.code==="MODULE_NOT_FOUND")return;console.error(`[ORM] Event '${event}' handler error:`,err);if(process.env.STACKS_ORM_EVENT_ERRORS!=="swallow")throw err}},broadcastOnFn=definition.traits?.broadcastOn,broadcastWithFn=definition.traits?.broadcastWith,broadcastingEnabled=typeof broadcastOnFn==="function"&&typeof broadcastWithFn==="function",dispatchBroadcast=async(event,model)=>{if(!broadcastingEnabled)return;if(eventsAreSuppressed())return;try{const channelsRaw=broadcastOnFn(model),channels=Array.isArray(channelsRaw)?channelsRaw:[channelsRaw],payload=broadcastWithFn(model),broadcast=(await import("@stacksjs/realtime").catch(()=>null))?.broadcast;if(typeof broadcast!=="function")return;for(const channel of channels){if(typeof channel!=="string"||channel.length===0)continue;await broadcast(channel,event,payload)}}catch(err){if(err&&typeof err==="object"&&"code"in err&&err.code==="MODULE_NOT_FOUND")return;console.error(`[ORM] Broadcast '${event}' handler error:`,err);if(process.env.STACKS_ORM_BROADCAST_ERRORS==="throw")throw err}},dispatchBeforeEvent=async(event,data)=>{if(eventsAreSuppressed())return!0;let eventsModule;try{eventsModule=modelEventsModule??=await import("@stacksjs/events");const{dispatchAndCollect}=eventsModule,results=await dispatchAndCollect(event,data);if(results.some((result)=>result.ok&&result.value===!1))return!1;const failure=results.find((result)=>!result.ok);if(failure&&!failure.ok)throw failure.error}catch(err){if(!eventsModule&&err&&typeof err==="object"&&"code"in err&&err.code==="MODULE_NOT_FOUND")return!0;console.error(`[ORM] Before-event '${event}' handler error:`,err);if(process.env.STACKS_ORM_EVENT_ERRORS!=="swallow")throw err}return!0},events=observe===!0?["create","update","delete"]:Array.isArray(observe)?observe:[],hooks={};if(events.includes("create")){hooks.beforeCreate=async(model)=>{log.debug(`[orm] Create ${modelName}`,model);if(!await dispatchBeforeEvent(`${modelName}:saving`,model))throw Error(`[ORM] ${modelName}:saving event cancelled the operation`);if(!await dispatchBeforeEvent(`${modelName}:creating`,model))throw Error(`[ORM] ${modelName}:creating event cancelled the operation`)};hooks.afterCreate=async(model)=>{const payload=toEventPayload(model);await dispatchEvent(`${modelName}:created`,payload);await dispatchEvent(`${modelName}:saved`,payload);await dispatchBroadcast(`${modelName}:created`,model)}}if(events.includes("update")){hooks.beforeUpdate=async(model)=>{log.debug(`[orm] Update ${modelName}#${model?.id??"unknown"}`,model);if(!await dispatchBeforeEvent(`${modelName}:saving`,model))throw Error(`[ORM] ${modelName}:saving event cancelled the operation`);if(!await dispatchBeforeEvent(`${modelName}:updating`,model))throw Error(`[ORM] ${modelName}:updating event cancelled the operation`)};hooks.afterUpdate=async(model)=>{const payload=toEventPayload(model);await dispatchEvent(`${modelName}:updated`,payload);await dispatchEvent(`${modelName}:saved`,payload);await dispatchBroadcast(`${modelName}:updated`,model)}}if(events.includes("delete")){hooks.beforeDelete=async(model)=>{log.debug(`[orm] Delete ${modelName}#${model?.id??"unknown"}`);if(!await dispatchBeforeEvent(`${modelName}:deleting`,model))throw Error(`[ORM] ${modelName}:deleting event cancelled the operation`)};hooks.afterDelete=async(model)=>{await dispatchEvent(`${modelName}:deleted`,toEventPayload(model));await dispatchBroadcast(`${modelName}:deleted`,model)}}return hooks}function mergeModelHooks(...sets){const merged={},names=new Set;for(const set of sets){if(!set)continue;for(const name of Object.keys(set))names.add(name)}for(const name of names){const fns=sets.map((s)=>s?.[name]).filter((fn)=>typeof fn==="function");if(!fns.length)continue;merged[name]=async(...args)=>{for(const fn of fns)await fn(...args)}}return Object.keys(merged).length?merged:void 0}function resolveSearchConfig(useSearch,definition){if(!useSearch)return null;const base=useSearch===!0?{}:typeof useSearch==="object"?{...useSearch}:null;if(!base)return null;if(definition)base.hidden=hiddenAttributeNames(definition);return base}function buildSearchHooks(definition){const config=resolveSearchConfig(definition.traits?.useSearch,definition);if(!config)return;const searchConfig=config,indexName=searchConfig.index??definition.table??snakeCase(`${definition.name}s`);async function projectDocument(model){if(typeof searchConfig.shapeMany==="function")try{const[doc]=await projectDocumentsFromTrait([model],searchConfig);return doc}catch(err){log.warn(`[orm/search] shapeMany() threw for ${definition.name}: ${err.message}`);return}if(typeof searchConfig.shape==="function")try{return await searchConfig.shape(model)}catch(err){log.warn(`[orm/search] shape() threw for ${definition.name}: ${err.message}`);return}const fromHook=wrapModelInstance(model).toSearchableObject?.();if(fromHook!==void 0)return fromHook;return model?._attributes??(typeof model?.toJSON==="function"?model.toJSON():null)}const syncDocument=async(model)=>{if(eventsAreSuppressed())return;const doc=await projectDocument(model);if(!doc)return;if(config.queueable){try{const{Jobs}=await import("@stacksjs/queue");await Jobs.dispatch("SyncSearchIndexJob",{index:indexName,op:"upsert",doc})}catch(err){log.warn(`[orm/search] queue dispatch failed for ${definition.name}; falling back to inline: ${err.message}`);await indexInline(indexName,doc,definition.name)}return}await indexInline(indexName,doc,definition.name)};return{afterCreate:syncDocument,afterUpdate:syncDocument,afterDelete:async(model)=>{if(eventsAreSuppressed())return;const id=model?.id??model?._attributes?.id;if(id==null)return;if(config.queueable){try{const{Jobs}=await import("@stacksjs/queue");await Jobs.dispatch("SyncSearchIndexJob",{index:indexName,op:"delete",id:Number(id)})}catch(err){log.warn(`[orm/search] queue dispatch failed for ${definition.name}#${id}; falling back to inline: ${err.message}`);await removeInline(indexName,Number(id),definition.name)}return}await removeInline(indexName,Number(id),definition.name)}}}const searchFailures=new Map;function warnSearchFailure(key,message){const seen=searchFailures.get(key)??0;searchFailures.set(key,seen+1);if(seen===0)log.warn(`[orm/search] ${message}`);else if(seen===9)log.warn(`[orm/search] ${key} is still failing; further identical warnings suppressed for this process.`)}async function indexInline(indexName,doc,modelName){try{const{useSearchEngine}=await import("@stacksjs/search-engine");await useSearchEngine().addDocument(indexName,doc)}catch(err){warnSearchFailure(`index:${modelName}`,`Failed to index ${modelName}: ${err.message}`)}}async function removeInline(indexName,id,modelName){try{const{useSearchEngine}=await import("@stacksjs/search-engine");await useSearchEngine().deleteDocument(indexName,id)}catch(err){warnSearchFailure(`remove:${modelName}`,`Failed to remove ${modelName}#${id}: ${err.message}`)}}function buildTraitMethods(definition){const methods={},{table:tableName,traits}=definition;if(!traits)return methods;if(traits.taggable)methods._taggable=createTaggableMethods(tableName);if(traits.categorizable)methods._categorizable=createCategorizableMethods(tableName);if(traits.commentable)methods._commentable=createCommentableMethods(tableName);if(traits.billable)methods._billable=createBillableMethods(tableName);if(traits.likeable){const likeableOpts=typeof traits.likeable==="object"?traits.likeable:void 0;methods._likeable=createLikeableMethods(tableName,likeableOpts)}const useAuth=traits.useAuth||traits.authenticatable;if(typeof useAuth==="object"&&useAuth&&"useTwoFactor"in useAuth&&useAuth.useTwoFactor)methods._twoFactor=createTwoFactorMethods();return methods}function applySoftDeletes(baseModel,definition,traitFlag){const helpers=createSoftDeleteMethods(baseModel,definition.primaryKey||"id"),options=resolveSoftDeleteOptions(traitFlag),parentDef=definition,modelName=definition.name.toLowerCase(),observeOn=definition.traits?.observe!=null&&definition.traits?.observe!==!1,fireRestoring=async(id)=>{if(!observeOn||eventsAreSuppressed())return!0;try{const{dispatchAsync}=modelEventsModule??=await import("@stacksjs/events"),results=await dispatchAsync(`${modelName}:restoring`,{id});if(Array.isArray(results)&&results.some((r)=>r===!1))return!1}catch(err){if(err?.code!=="MODULE_NOT_FOUND")console.error(`[ORM] Before-event '${modelName}:restoring' handler error:`,err)}return!0},fireRestored=async(id)=>{if(!observeOn||eventsAreSuppressed())return;try{const{dispatch}=modelEventsModule??=await import("@stacksjs/events");await dispatch(`${modelName}:restored`,{id})}catch(err){if(err?.code!=="MODULE_NOT_FOUND")console.error(`[ORM] Event '${modelName}:restored' handler error:`,err)}},transactional=process.env.STACKS_ORM_CASCADE_SWALLOW!=="true",softDeleteFn=async(id)=>{if(!options.cascade?.length||!transactional){const ok=await helpers.softDelete(id);if(ok&&options.cascade?.length)await cascadeSoftDelete(parentDef,options,id,"softDelete");return ok}const{db}=await import("@stacksjs/database/runtime");return await db.transaction(async()=>{const ok=await helpers.softDelete(id);if(ok)await cascadeSoftDelete(parentDef,options,id,"softDelete");return ok})},restoreFn=async(id)=>{if(!await fireRestoring(id))return!1;if(!options.cascade?.length||!transactional){const ok=await helpers.restore(id);if(ok){if(options.cascade?.length)await cascadeSoftDelete(parentDef,options,id,"restore");await fireRestored(id)}return ok}const{db}=await import("@stacksjs/database/runtime"),ok=await db.transaction(async()=>{const inner=await helpers.restore(id);if(inner)await cascadeSoftDelete(parentDef,options,id,"restore");return inner});if(ok)await fireRestored(id);return ok};baseModel.softDelete=softDeleteFn;baseModel.restore=restoreFn;baseModel.forceDelete=helpers.forceDelete;baseModel.withTrashed=helpers.withTrashed;baseModel.onlyTrashed=helpers.onlyTrashed;baseModel.delete=softDeleteFn}export function toAttrs(value){if(value==null)return value;if(Array.isArray(value))return value.map((v)=>toAttrs(v));if(typeof value!=="object")return value;const candidate=value;if(typeof candidate.toJSON==="function")return candidate.toJSON();if(candidate._attributes&&typeof candidate._attributes==="object")return candidate._attributes;return value}
@@ -0,0 +1 @@
1
+ import{defineModel,MODEL_DEFINITION}from"./define-model";const RELATION_KEYS=["belongsTo","hasOne","hasMany","belongsToMany","morphMany","morphOne"],SHALLOW_MERGE_KEYS=["traits","dashboard"];function baseDefinitionOf(model){const stashed=model?.[MODEL_DEFINITION];if(stashed&&typeof stashed==="object")return stashed;if(model&&typeof model==="object"&&"name"in model&&"attributes"in model)return model;throw TypeError("extendModel() expects a model created by defineModel(), or a model definition. Import the framework model itself rather than its table name.")}function mergeRelations(base,patch){const first=Array.isArray(base)?base:[],second=Array.isArray(patch)?patch:[];if(!first.length&&!second.length)return;return[...new Set([...first,...second])]}function highestOrder(attributes){if(!attributes)return 0;return Object.values(attributes).reduce((highest,attribute)=>{const order=Number(attribute?.order);return Number.isFinite(order)&&order>highest?order:highest},0)}export function mergeModelDefinition(base,extension){const merged={...base,...extension},baseAttributes=base.attributes??{},patchAttributes=extension.attributes??{};if(extension.attributes){let nextOrder=highestOrder(baseAttributes);const added=Object.fromEntries(Object.entries(patchAttributes).map(([name,attribute])=>{const isNew=!(name in baseAttributes),hasOrder=Number.isFinite(Number(attribute?.order));if(isNew&&!hasOrder)return[name,{...attribute,order:++nextOrder}];return[name,attribute]}));merged.attributes={...baseAttributes,...added}}for(const key of RELATION_KEYS){const relations=mergeRelations(base[key],extension[key]);if(relations)merged[key]=relations}for(const key of SHALLOW_MERGE_KEYS){const first=base[key],second=extension[key];if(first&&second&&typeof first==="object"&&typeof second==="object")merged[key]={...first,...second}}return merged}export function extendModel(base,extension){return defineModel(mergeModelDefinition(baseDefinitionOf(base),extension))}
@@ -0,0 +1 @@
1
+ import{randomUUID}from"node:crypto";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{transaction}from"../transaction";import{assertGdprPlan,GDPR_SUBJECT_MODEL,resolveGdprPlan}from"./plan";export const GDPR_AUDIT_TABLE="gdpr_requests";const CHUNK=500;export class GdprSubjectNotFoundError extends Error{constructor(reference){super(`No user matches '${String(reference)}'.`);this.name="GdprSubjectNotFoundError"}}async function loadPlan(models){if(models)return assertGdprPlan(resolveGdprPlan(models));const{loadGdprModels}=await import("./models");return assertGdprPlan(resolveGdprPlan(await loadGdprModels()))}async function liveTables(handle=db){const dialect=String(getDatabaseDialect()),query=dialect==="sqlite"?"SELECT name FROM sqlite_master WHERE type = 'table'":dialect==="mysql"?"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()":dialect==="postgres"?"SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()":null;if(!query)return null;const rows=await handle.unsafe(query).execute();return new Set(rows.map((row)=>String(row.name??row.NAME??"").toLowerCase()).filter(Boolean))}const MISSING_TABLE=/no such table|does not exist|doesn't exist|unknown table|undefined table|ER_NO_SUCH_TABLE/i;export async function gdprTableExists(table,handle=db){const tables=await liveTables(handle);if(tables)return tables.has(table.toLowerCase());try{await handle.selectFrom(table).selectAll().limit(1).execute();return!0}catch(error){if(MISSING_TABLE.test(error instanceof Error?error.message:String(error)))return!1;throw error}}function subjectPlanFor(plan){const user=plan.models.find((model)=>model.model===GDPR_SUBJECT_MODEL);return{table:user?.table??"users",primaryKey:user?.primaryKey??"id"}}function normalizeReference(reference){if(typeof reference==="number")return{kind:"id",value:reference};const trimmed=String(reference).trim();if(/^\d+$/.test(trimmed))return{kind:"id",value:Number(trimmed)};if(trimmed.includes("@"))return{kind:"email",value:trimmed};return{kind:"uuid",value:trimmed}}export async function resolveGdprSubject(reference,options={},handle=db){const plan=await loadPlan(options.models);return findSubject(plan,reference,handle)}async function findSubject(plan,reference,handle){const{table,primaryKey}=subjectPlanFor(plan),ref=normalizeReference(reference);let row;if(ref.kind==="id")row=(await handle.selectFrom(table).selectAll().where(primaryKey,"=",ref.value).limit(1).execute())[0];else if(ref.kind==="email")for(const candidate of new Set([ref.value,ref.value.toLowerCase()])){row=(await handle.selectFrom(table).selectAll().where("email","=",candidate).limit(1).execute())[0];if(row)break}else row=(await handle.selectFrom(table).selectAll().where("uuid","=",ref.value).limit(1).execute())[0];if(!row)return null;return{id:row[primaryKey],email:typeof row.email==="string"?row.email:null}}function chunks(values,size=CHUNK){const out=[];for(let i=0;i<values.length;i+=size)out.push(values.slice(i,i+size));return out}function dedupe(rows,primaryKey){const seen=new Map;for(const row of rows)seen.set(String(row[primaryKey]),row);return[...seen.values()].sort((a,b)=>String(a[primaryKey]).localeCompare(String(b[primaryKey]),void 0,{numeric:!0}))}async function matchRows(handle,plans,model,subject,memo){const cached=memo.get(model.model);if(cached)return cached;const link=model.subject;let rows=[];if(link?.kind==="column")for(const column of link.columns){let query=handle.selectFrom(model.table).selectAll().where(column,"=",subject.id);for(const[key,value]of Object.entries(link.where))query=query.where(key,"=",value);rows.push(...await query.execute())}else if(link?.kind==="email"){if(subject.email)for(const candidate of new Set([subject.email,subject.email.toLowerCase()]))rows.push(...await handle.selectFrom(model.table).selectAll().where(link.column,"=",candidate).execute())}else if(link?.kind==="via"){const parent=plans.get(link.model),parentIds=(await matchRows(handle,plans,parent,subject,memo)).map((row)=>row[parent.primaryKey]).filter((id)=>id!==null&&id!==void 0);for(const ids of chunks(parentIds))rows.push(...await handle.selectFrom(model.table).selectAll().where(link.foreignKey,"in",ids).execute())}rows=dedupe(rows,model.primaryKey);memo.set(model.model,rows);return rows}function exportable(value){if(typeof value==="bigint")return Number.isSafeInteger(Number(value))?Number(value):value.toString();if(value instanceof Date)return value.toISOString();return value}function project(model,row){const out={};for(const column of model.exportColumns)if(column in row)out[column]=exportable(row[column]);for(const field of model.personal)if(field.export&&field.column in row)out[field.column]=exportable(row[field.column]);return out}function targetValues(model,row){const values={};for(const field of model.personal)values[field.column]=field.perRow?`erased-${String(row[model.primaryKey])}`:field.anonymizeTo;return values}function same(stored,target){if(stored===target)return!0;if(stored===null||stored===void 0||target===null||target===void 0)return(stored===null||stored===void 0)&&(target===null||target===void 0);if(typeof target==="boolean")return Number(stored)===Number(target);return String(stored)===String(target)}function needsAnonymizing(model,row){const target=targetValues(model,row);return Object.entries(target).some(([column,value])=>!same(row[column],value))}async function anonymizeRows(handle,model,rows){if(!rows.length)return;if(model.personal.some((field)=>field.perRow)){for(const row of rows)await handle.updateTable(model.table).set(targetValues(model,row)).where(model.primaryKey,"=",row[model.primaryKey]).execute();return}const values=targetValues(model,rows[0]);for(const ids of chunks(rows.map((row)=>row[model.primaryKey])))await handle.updateTable(model.table).set(values).where(model.primaryKey,"in",ids).execute()}async function deleteRows(handle,model,rows){for(const ids of chunks(rows.map((row)=>row[model.primaryKey])))await handle.deleteFrom(model.table).where(model.primaryKey,"in",ids).execute()}async function recordAudit(handle,type,subjectId,actor,summary){const now=sqlDateTime(new Date);await handle.insertInto(GDPR_AUDIT_TABLE).values({uuid:randomUUID(),type,subject_id:subjectId!==null&&Number.isSafeInteger(Number(subjectId))?Number(subjectId):null,actor,status:"completed",summary:JSON.stringify(subjectId!==null&&!Number.isSafeInteger(Number(subjectId))?{subject:String(subjectId),...summary}:summary),occurred_at:now,created_at:now}).execute()}async function requireAuditTable(){if(!await gdprTableExists(GDPR_AUDIT_TABLE))throw Error(`The '${GDPR_AUDIT_TABLE}' table does not exist, so this request cannot be recorded. Run \`buddy migrate\` first.`)}async function existingTables(models){const present=[],skipped=[],tables=await liveTables(),known=new Map;for(const model of models){if(!known.has(model.table))known.set(model.table,tables?tables.has(model.table.toLowerCase()):await gdprTableExists(model.table));if(known.get(model.table))present.push(model);else skipped.push(model.table)}return{present,skipped:[...new Set(skipped)].sort()}}export async function exportSubjectData(reference,options={}){const plan=await loadPlan(options.models),subject=await findSubject(plan,reference,db);if(!subject)throw new GdprSubjectNotFoundError(reference);await requireAuditTable();const linked=plan.models.filter((model)=>model.subject),{present}=await existingTables(linked),plans=new Map(present.map((model)=>[model.model,model]));for(const model of present)if(model.subject?.kind==="via"&&!plans.has(model.subject.model))plans.delete(model.model);const memo=new Map,data={},processing={};for(const model of[...plans.values()].sort((a,b)=>a.model.localeCompare(b.model))){const rows=await matchRows(db,plans,model,subject,memo);if(!rows.length)continue;data[model.model]=rows.map((row)=>project(model,row));processing[model.model]={purpose:model.purpose,basis:model.basis,retentionDays:model.retention?.days??null}}await recordAudit(db,"access",subject.id,options.actor??"system",{models:Object.fromEntries(Object.entries(data).map(([model,rows])=>[model,rows.length]))});return{subject:{id:subject.id},generatedAt:new Date().toISOString(),data,processing}}function erasureOrder(models){return[...models].sort((a,b)=>{const aSelf=a.model===GDPR_SUBJECT_MODEL?1:0,bSelf=b.model===GDPR_SUBJECT_MODEL?1:0;if(aSelf!==bSelf)return aSelf-bSelf;if(a.depth!==b.depth)return b.depth-a.depth;return a.model.localeCompare(b.model)})}export async function eraseSubject(reference,options={}){const dryRun=options.dryRun===!0,plan=await loadPlan(options.models),subject=await findSubject(plan,reference,db);if(!subject)throw new GdprSubjectNotFoundError(reference);if(!dryRun)await requireAuditTable();const linked=plan.models.filter((model)=>model.subject),{present,skipped}=await existingTables(linked),plans=new Map(present.map((model)=>[model.model,model]));for(const model of present)if(model.subject?.kind==="via"&&!plans.has(model.subject.model))plans.delete(model.model);const work=async(handle)=>{const memo=new Map;for(const model of plans.values())await matchRows(handle,plans,model,subject,memo);const changes=[];for(const model of erasureOrder([...plans.values()])){const rows=memo.get(model.model)??[];if(model.erasure==="delete"){if(!dryRun)await deleteRows(handle,model,rows);changes.push({model:model.model,table:model.table,action:"delete",matched:rows.length,changed:rows.length,fields:[]})}else if(model.erasure==="anonymize"){const pending=rows.filter((row)=>needsAnonymizing(model,row));if(!dryRun)await anonymizeRows(handle,model,pending);changes.push({model:model.model,table:model.table,action:"anonymize",matched:rows.length,changed:pending.length,fields:model.personal.map((field)=>field.column)})}else changes.push({model:model.model,table:model.table,action:"keep",matched:rows.length,changed:0,fields:[]})}if(!dryRun)await recordAudit(handle,"erasure",subject.id,options.actor??"system",{changes:changes.map(({model,action,matched,changed})=>({model,action,matched,changed})),skipped});return changes},changes=dryRun?await work(db):await transaction(work);let credentialsRevoked=!1;if(!dryRun&&options.revokeCredentials!==!1)credentialsRevoked=await revokeCredentials(subject.id);return{subject:{id:subject.id},dryRun,changes:changes.sort((a,b)=>a.model.localeCompare(b.model)),skipped,credentialsRevoked}}async function revokeCredentials(subjectId){let auth;try{auth=await import("@stacksjs/auth")}catch{return!1}const id=Number(subjectId);if(!Number.isSafeInteger(id))return!1;if(auth.revokeAllTokens&&await gdprTableExists("oauth_access_tokens"))await auth.revokeAllTokens(id);if(auth.sessionDestroyAll)await auth.sessionDestroyAll(id);return!0}const DAY_MS=86400000;export async function pruneRetainedData(options={}){const dryRun=options.dryRun===!0,now=options.now??new Date,policies=(await loadPlan(options.models)).models.filter((model)=>model.retention);if(!policies.length)return{dryRun,cutoffs:{},changes:[],skipped:[]};if(!dryRun)await requireAuditTable();const{present,skipped}=await existingTables(policies),changes=[],cutoffs={};for(const model of present){const retention=model.retention,cutoff=new Date(now.getTime()-retention.days*DAY_MS),bound=sqlDateTime(new Date(cutoff.getTime()+DAY_MS));cutoffs[model.model]=cutoff.toISOString();const expired=[];let last;for(;;){let query=db.selectFrom(model.table).selectAll().where(retention.column,"<",bound);if(last!==void 0)query=query.where(model.primaryKey,">",last);const page=await query.orderBy(model.primaryKey,"asc").limit(CHUNK).execute();if(!page.length)break;for(const row of page){const at=parseSqlDateTime(row[retention.column]);if(at&&at.getTime()<cutoff.getTime())expired.push(row)}last=page[page.length-1][model.primaryKey];if(page.length<CHUNK)break}const pending=retention.action==="delete"?expired:expired.filter((row)=>needsAnonymizing(model,row));if(!dryRun&&pending.length)await transaction(async(handle)=>{if(retention.action==="delete")await deleteRows(handle,model,pending);else await anonymizeRows(handle,model,pending)});changes.push({model:model.model,table:model.table,action:retention.action,matched:expired.length,changed:pending.length,fields:retention.action==="anonymize"?model.personal.map((field)=>field.column):[]})}if(!dryRun&&changes.some((change)=>change.changed>0))await recordAudit(db,"retention",null,options.actor??"scheduler",{changes:changes.map(({model,action,matched,changed})=>({model,action,matched,changed})),cutoffs});return{dryRun,cutoffs,changes:changes.sort((a,b)=>a.model.localeCompare(b.model)),skipped}}
@@ -0,0 +1 @@
1
+ export{eraseSubject,exportSubjectData,GDPR_AUDIT_TABLE,GdprSubjectNotFoundError,gdprTableExists,pruneRetainedData,resolveGdprSubject}from"./engine";export{loadGdprModels}from"./models";export{assertGdprPlan,GDPR_SUBJECT_MODEL,GdprDeclarationError,gdprTableName,resolveGdprPlan}from"./plan";export{buildProcessingRegister,renderProcessingRegister}from"./register";
@@ -0,0 +1 @@
1
+ import{path}from"@stacksjs/path";import{loadModelRegistry}from"../model-registry";export async function loadGdprModels(){return loadModelRegistry({defaultsRoot:path.frameworkPath("defaults/app/Models"),userRoot:path.userModelsPath()})}
@@ -0,0 +1,3 @@
1
+ import{plural,snakeCase}from"@stacksjs/strings";const ERASURE_ACTIONS=new Set(["anonymize","delete","keep"]),RETENTION_ACTIONS=new Set(["anonymize","delete"]),LAWFUL_BASES=new Set(["consent","contract","legal_obligation","vital_interests","public_task","legitimate_interests"]);export const GDPR_SUBJECT_MODEL="User";export class GdprDeclarationError extends Error{problems;constructor(problems){super(`GDPR declarations cannot be honoured:
2
+ ${problems.map((p)=>` - ${p.model}: ${p.message}`).join(`
3
+ `)}`);this.name="GdprDeclarationError";this.problems=problems}}function modelList(models){return(Array.isArray(models)?models.map((definition)=>[definition.name??"",definition]):Object.entries(models)).map(([key,definition])=>({name:String(definition?.name??key),definition})).filter((entry)=>entry.name&&entry.definition&&typeof entry.definition==="object").sort((a,b)=>a.name.localeCompare(b.name))}export function gdprTableName(name,definition){return definition.table??snakeCase(plural(name))}function belongsToEntries(definition){const raw=definition.belongsTo,list=Array.isArray(raw)?raw:raw&&typeof raw==="object"?Object.values(raw):[],entries=[];for(const item of list){if(typeof item==="string"){entries.push({model:item,foreignKey:`${snakeCase(item)}_id`});continue}if(item&&typeof item==="object"&&typeof item.model==="string"){const relation=item;entries.push({model:relation.model,foreignKey:relation.foreignKey??`${snakeCase(relation.model)}_id`})}}return entries}function personalOptions(value){if(value===!0)return{};if(value&&typeof value==="object")return value;return null}function anonymizedValue(attribute,options){if(options.anonymize!==void 0)return{perRow:!1,value:options.anonymize};if(!(attribute.required===!0&&attribute.nullable!==!0))return{perRow:!1,value:null};const type=attribute.validation?.rule?.name??attribute.type??"string";if(type==="string"||type==="text")return attribute.unique?{perRow:!0,value:null}:{perRow:!1,value:"[erased]"};if(attribute.unique)return{problem:`is a NOT NULL unique ${type} column; declare personal: { anonymize: <value> } or make it nullable`};if(type==="number"||type==="integer"||type==="float"||type==="decimal")return{perRow:!1,value:0};if(type==="boolean")return{perRow:!1,value:!1};if(type==="json")return{perRow:!1,value:"{}"};return{problem:`is a NOT NULL ${type} column with no safe placeholder; declare personal: { anonymize: <value> }`}}export function resolveGdprPlan(models){const plans=new Map,viaOf=new Map,unclassified=[],problems=[],problem=(model,message)=>{problems.push({model,message})};for(const{name,definition}of modelList(models)){const attributes=definition.attributes??{},gdpr=definition.traits?.gdpr,belongsTo=belongsToEntries(definition),userRelation=belongsTo.find((entry)=>entry.model===GDPR_SUBJECT_MODEL),personalAttributes=Object.entries(attributes).map(([attribute,shape])=>({attribute,shape,options:personalOptions(shape?.personal)})).filter((entry)=>entry.options!==null);if(!gdpr&&personalAttributes.length===0){if(userRelation&&name!==GDPR_SUBJECT_MODEL)unclassified.push(name);continue}if(gdpr!==void 0&&(gdpr===null||typeof gdpr!=="object")){problem(name,"`traits.gdpr` must be an object");continue}const table=gdprTableName(name,definition),primaryKey=definition.primaryKey??"id";let subject=null,via;const declared=gdpr?.subject;if(declared===void 0){if(name===GDPR_SUBJECT_MODEL)subject={kind:"column",columns:[primaryKey],where:{}};else if(userRelation)subject={kind:"column",columns:[userRelation.foreignKey],where:{}}}else if(typeof declared==="string")subject={kind:"column",columns:[declared],where:{}};else if(Array.isArray(declared))subject={kind:"column",columns:[...declared].map(String),where:{}};else if(declared&&typeof declared==="object"&&"column"in declared)subject={kind:"column",columns:Array.isArray(declared.column)?[...declared.column].map(String):[String(declared.column)],where:{...declared.where??{}}};else if(declared&&typeof declared==="object"&&"via"in declared){const relation=belongsTo.find((entry)=>entry.model===declared.via);if(!relation)problem(name,`subject { via: '${declared.via}' } names a model this one does not declare in belongsTo`);else{via=relation.model;subject={kind:"via",model:relation.model,foreignKey:relation.foreignKey}}}else if(declared&&typeof declared==="object"&&"email"in declared)subject={kind:"email",column:String(declared.email)};else problem(name,"subject must be a column name, a list of columns, { column, where }, { via } or { email }");if(subject?.kind==="column"&&subject.columns.length===0)problem(name,"subject names no column");const erasure=gdpr?.erasure??"anonymize";if(!ERASURE_ACTIONS.has(erasure))problem(name,`erasure must be one of ${[...ERASURE_ACTIONS].join(", ")}, got '${String(erasure)}'`);let retention=null;if(gdpr?.retention!==void 0){const policy=gdpr.retention,action=policy?.action??"delete";if(!policy||typeof policy!=="object"||!Number.isInteger(policy.days)||policy.days<=0)problem(name,"retention.days must be a positive whole number of days");else if(!RETENTION_ACTIONS.has(action))problem(name,`retention.action must be one of ${[...RETENTION_ACTIONS].join(", ")}, got '${String(action)}'`);else retention={days:policy.days,column:policy.column??"created_at",action}}if(gdpr?.basis!==void 0&&!LAWFUL_BASES.has(gdpr.basis))problem(name,`basis must be one of ${[...LAWFUL_BASES].join(", ")}, got '${String(gdpr.basis)}'`);const anonymizes=erasure==="anonymize"&&subject!==null||retention?.action==="anonymize",personal=[];for(const{attribute,shape,options}of personalAttributes){const resolved=anonymizedValue(shape,options);if("problem"in resolved){if(anonymizes)problem(name,`personal attribute '${attribute}' ${resolved.problem}`);personal.push({attribute,column:snakeCase(attribute),export:options.export!==!1,perRow:!1,anonymizeTo:null});continue}personal.push({attribute,column:snakeCase(attribute),export:options.export!==!1,perRow:resolved.perRow,anonymizeTo:resolved.value})}if(subject!==null&&erasure==="anonymize"&&personal.length===0)problem(name,"declares a subject but no personal attributes, so 'anonymize' would change nothing; mark the personal attributes, or declare erasure 'keep' or 'delete'");if(retention?.action==="anonymize"&&personal.length===0)problem(name,"retention anonymizes, but no attribute is marked personal");plans.set(name,{model:name,table,primaryKey,subject,personal,erasure,retention,purpose:gdpr?.purpose??null,basis:gdpr?.basis??null,depth:0,exportColumns:[...new Set([primaryKey,"uuid","created_at","updated_at"])]});if(via)viaOf.set(name,via)}const depthOf=(name,seen)=>{const via=viaOf.get(name);if(!via)return 0;if(seen.has(name))return null;if(!plans.get(via)?.subject)return null;seen.add(name);const parentDepth=depthOf(via,seen);return parentDepth===null?null:parentDepth+1};for(const plan of plans.values()){const via=viaOf.get(plan.model);if(!via)continue;if(!plans.get(via)?.subject){problem(plan.model,`subject { via: '${via}' } points at a model that declares no subject of its own`);continue}const depth=depthOf(plan.model,new Set);if(depth===null)problem(plan.model,`subject { via: '${via}' } is part of a cycle`);else plan.depth=depth}return{models:[...plans.values()],unclassified:unclassified.sort(),problems}}export function assertGdprPlan(plan){if(plan.problems.length)throw new GdprDeclarationError(plan.problems);return plan}
@@ -0,0 +1,4 @@
1
+ function describeSubject(model){const link=model.subject;if(!link)return"none (not reachable by access or erasure)";if(link.kind==="via")return`via ${link.model} (\`${link.foreignKey}\`)`;if(link.kind==="email")return`email address in \`${link.column}\``;const columns=link.columns.map((column)=>`\`${column}\``).join(" or "),where=Object.entries(link.where).map(([key,value])=>`${key} = ${String(value)}`).join(", ");return where?`${columns} where ${where}`:columns}export function buildProcessingRegister(plan){return{entries:plan.models.map((model)=>({model:model.model,table:model.table,subject:describeSubject(model),personal:model.personal.map((field)=>field.column),notExported:model.personal.filter((field)=>!field.export).map((field)=>field.column),erasure:model.subject?model.erasure:null,retention:model.retention?`${model.retention.action} after ${model.retention.days} days (\`${model.retention.column}\`)`:null,basis:model.basis,purpose:model.purpose})).sort((a,b)=>a.model.localeCompare(b.model)),unclassified:[...plan.unclassified].sort()}}function cell(value){return(value??"-").replaceAll("|","\\|").replaceAll(`
2
+ `," ")}export function renderProcessingRegister(register,format="markdown"){if(format==="json")return`${JSON.stringify(register,null,2)}
3
+ `;const lines=["# Processing register","","<!-- Generated by `buddy gdpr:register` from the model declarations. Do not edit by hand. -->","","Every model that declares personal data or a `gdpr` trait, what erasure does to its rows, and how long they are kept. Change the model, then regenerate this file.","","| Model | Table | Subject | Personal data | Erasure | Retention | Basis | Purpose |","| --- | --- | --- | --- | --- | --- | --- | --- |"];for(const entry of register.entries){const personal=entry.personal.length?entry.personal.map((column)=>entry.notExported.includes(column)?`\`${column}\` (not exported)`:`\`${column}\``).join(", "):"-";lines.push(`| ${[entry.model,`\`${entry.table}\``,cell(entry.subject),personal,cell(entry.erasure),cell(entry.retention),cell(entry.basis),cell(entry.purpose)].join(" | ")} |`)}lines.push("");lines.push("## Unclassified");lines.push("");if(register.unclassified.length){lines.push("These models belong to a user but declare neither personal attributes nor a `gdpr` trait, so access and erasure do not reach them. Classify each one, even if only as `erasure: 'keep'`.");lines.push("");for(const model of register.unclassified)lines.push(`- ${model}`)}else lines.push("None. Every model that belongs to a user is classified.");lines.push("");return lines.join(`
4
+ `)}
File without changes
@@ -0,0 +1,21 @@
1
+ import process from"node:process";import{log}from"@stacksjs/logging";import{path}from"@stacksjs/path";import{fs}from"@stacksjs/storage";import{deriveMigrationTables}from"./migration-tables";function resolveDialect(explicit){if(explicit)return explicit;if(process.env.STACKS_CANONICAL_FEATURES==="1")return"sqlite";const configured=String(process.env.DB_CONNECTION??"").toLowerCase();return configured==="postgres"||configured==="mysql"?configured:"sqlite"}function booleanType(dialect){return dialect==="postgres"?"boolean":"number"}function snakeCase(str){return str.replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").replace(/([a-z\d])([A-Z])/g,"$1_$2").toLowerCase()}function fromDefault(attr,dialect){const value=attr.default;if(typeof value==="boolean")return booleanType(dialect);if(typeof value==="number")return"number";if(typeof value==="string")return"string";return"unknown"}function ruleOf(attr){const validation=attr.validation;if(!validation||typeof validation!=="object")return null;const rule=validation.rule;return rule&&typeof rule==="object"?rule:null}function enumUnion(rule){if(!rule||String(rule.name??"")!=="enum")return null;const literals=(Array.isArray(rule.allowedValues)?rule.allowedValues:[]).filter((one)=>typeof one==="string"||typeof one==="number").map((one)=>typeof one==="number"?String(one):JSON.stringify(one));return literals.length>0?literals.join(" | "):null}function attributeToTsType(attr,dialect){const rule=ruleOf(attr),literals=enumUnion(rule);if(literals!==null)return attr.nullable===!0?`${literals} | null`:literals;const declared=typeof attr.type==="string"?attr.type.toLowerCase():String(rule?.name??"").toLowerCase(),base=(()=>{switch(declared){case"string":case"text":case"longtext":case"mediumtext":case"tinytext":case"char":case"varchar":case"enum":case"uuid":case"date":case"datetime":case"timestamp":case"timestamptz":case"password":case"time":case"year":return"string";case"number":case"integer":case"int":case"tinyint":case"smallint":case"mediumint":case"float":case"double":case"decimal":case"numeric":return"number";case"bigint":return"number | bigint";case"boolean":case"bool":return booleanType(dialect);case"json":case"jsonb":return"unknown";case"binary":case"blob":case"bytea":return"Uint8Array";case"unix":return"number";case"array":return"unknown[]";case"object":return"Record<string, unknown>";default:return fromDefault(attr,dialect)}})();return attr.nullable===!0?`${base} | null`:base}function deriveFkColumns(model){const out={},rel=model.belongsTo;if(!rel)return out;const entries=Array.isArray(rel)?rel:Object.values(rel);for(const entry of entries){if(typeof entry==="string"){out[`${snakeCase(entry)}_id`]="number";continue}if(entry&&typeof entry==="object"){const relation=entry;if(typeof relation.foreignKey==="string"){out[relation.foreignKey]="number";continue}if(typeof relation.model==="string")out[`${snakeCase(relation.model)}_id`]="number"}}return out}function pivotTableName(a,b){const[first,second]=[snakeCase(a),snakeCase(b)].sort();return`${first}_${second}`}function pivotColumnToTsType(attribute,dialect){const value=attribute.default,base=typeof value==="string"?"string":typeof value==="number"?"number":typeof value==="boolean"?booleanType(dialect):value instanceof Date?"string":"unknown";return attribute.nullable?`${base} | null`:base}function derivePivotTables(modelName,model,dialect){const rel=model.belongsToMany;if(!rel)return[];const out=[],entries=Array.isArray(rel)?rel:Object.values(rel);for(const entry of entries){let related,table,firstFk,secondFk,pivotColumns={},timestamps=!0;if(typeof entry==="string")related=entry;else if(entry&&typeof entry==="object"&&"model"in entry){const obj=entry;related=obj.model;if("pivotTable"in obj||"firstForeignKey"in obj||"secondForeignKey"in obj){table=obj.pivotTable;firstFk=obj.firstForeignKey;secondFk=obj.secondForeignKey}else{const modern=entry;table=modern.table;firstFk=modern.foreignKey;secondFk=modern.relatedKey;pivotColumns=modern.pivot?.columns??{};timestamps=Boolean(modern.pivot?.timestamps)}}else continue;const tableName=table??pivotTableName(modelName,related),fkA=firstFk??`${snakeCase(modelName)}_id`,fkB=secondFk??`${snakeCase(related)}_id`,columns={id:"number",[fkA]:"number",[fkB]:"number"};for(const[column,attribute]of Object.entries(pivotColumns))columns[column]=pivotColumnToTsType(attribute,dialect);if(timestamps){columns.created_at="string";columns.updated_at="string | null"}out.push({table:tableName,columns})}return out}function usersGuaranteed(dialect){return{email_verified_at:"string | null",password_changed_at:"string | null",two_factor_secret:"string | null",two_factor_enabled:`${booleanType(dialect)} | null`,two_factor_last_used_step:"number | null",stripe_id:"string | null"}}function deriveSystemColumns(model,dialect){const out={id:"number"},traits=model.traits??{};if(traits.useUuid)out.uuid="string";if((traits.useTimestamps??traits.timestampable)!==!1){out.created_at="string";out.updated_at="string | null"}if(traits.useSoftDeletes??traits.softDeletable)out.deleted_at="string | null";if((model.table??"")==="users"||model.name==="User")Object.assign(out,usersGuaranteed(dialect));return out}function deriveAttributeColumns(model,dialect){const out={},attributes=model.attributes??{};for(const[name,attr]of Object.entries(attributes))out[snakeCase(name)]=attributeToTsType(attr,dialect);return out}function renderTableEntry(table,columns,indent=2){const outer=" ".repeat(indent),inner=" ".repeat(indent+1),cols=Object.entries(columns).map(([col,ty])=>`${inner}${col}: ${ty}`).join(`
2
+ `),key=/^[A-Z_a-z]\w*$/.test(table)?table:`'${table}'`;return`${outer}${key}: {
3
+ ${inner}// columns
4
+ ${cols}
5
+ ${outer}}`}const APP_HEADER=`/* eslint-disable */
6
+ // AUTO-GENERATED by \`buddy generate:db-types\` \u2014 do not edit.
7
+ // Regenerate after adding/removing a model or changing its attributes.
8
+ // See stacksjs/stacks#1923.
9
+
10
+ declare module '@stacksjs/database' {
11
+ interface DatabaseSchema {
12
+ `,APP_FOOTER=` }
13
+ }
14
+
15
+ export {}
16
+ `,FRAMEWORK_HEADER="/* eslint-disable */\n// AUTO-GENERATED by `buddy generate:db-types --framework` \u2014 do not edit.\n//\n// The shapes of the tables the framework itself ships, so framework code gets\n// real rows from `db.selectFrom(...)` without asserting them. An application's\n// own tables (and its overrides of these) live in `DatabaseSchema`, which takes\n// precedence - see `RowOf` in `./utils`.\n\nexport interface FrameworkSchema {\n",FRAMEWORK_FOOTER=`}
17
+ `;function headerFor(target){return target==="framework"?FRAMEWORK_HEADER:APP_HEADER}function footerFor(target){return target==="framework"?FRAMEWORK_FOOTER:APP_FOOTER}async function loadModelsFrom(dir,recursive,failures=[]){const out=[];if(!fs.existsSync(dir))return out;const entries=fs.readdirSync(dir,{withFileTypes:!0});for(const entry of entries){const fullPath=path.join(dir,entry.name);if(entry.isDirectory()){if(recursive){const sub=await loadModelsFrom(fullPath,!0,failures);out.push(...sub)}continue}if(!entry.name.endsWith(".ts"))continue;if(entry.name.startsWith("_")||entry.name.startsWith("index"))continue;try{const module=await(path.isAbsolute(fullPath)?import(fullPath):import(path.resolve(fullPath))),def=module.default||module;if(def?.name&&(def.attributes||def.table))out.push({filePath:fullPath,model:def})}catch(err){failures.push({file:fullPath,error:err.message})}}return out}export async function buildDatabaseSchema(options={}){const dialect=resolveDialect(options.dialect),modelsDir=options.modelsDir??path.userModelsPath(),defaultsDir=options.defaultsDir??path.frameworkPath("defaults/app/Models"),outFile=options.outFile??path.projectPath("database/types.d.ts"),errors=[],allModels=[];for(const[dir,recursive]of[[modelsDir,!1],[defaultsDir,!0]])try{const found=await loadModelsFrom(dir,recursive,errors);allModels.push(...found)}catch(err){errors.push({file:dir,error:err.message})}const byName=new Map;for(const item of allModels)byName.set(item.model.name??path.basename(item.filePath).replace(/\.ts$/,""),item);for(const item of allModels)if(item.filePath.startsWith(modelsDir))byName.set(item.model.name??path.basename(item.filePath).replace(/\.ts$/,""),item);const tables=[];for(const[name,{model}]of byName){const tableName=model.table??`${snakeCase(name)}s`,columns={...deriveSystemColumns(model,dialect),...deriveAttributeColumns(model,dialect),...deriveFkColumns(model)};tables.push({table:tableName,model:name,columns})}const pivotByTable=new Map;for(const[name,{model}]of byName)for(const pivot of derivePivotTables(name,model,dialect)){if(pivotByTable.has(pivot.table))continue;pivotByTable.set(pivot.table,{table:pivot.table,model:`(${pivot.table} pivot)`,columns:pivot.columns})}tables.push(...pivotByTable.values());if(options.migrationsDir!==!1){const migrationsDir=options.migrationsDir??path.resolve(path.dirname(outFile),"migrations"),owned=new Set(tables.map((entry)=>entry.table)),derived=deriveMigrationTables(migrationsDir,dialect);for(const entry of derived.tables){if(owned.has(entry.table))continue;owned.add(entry.table);tables.push({table:entry.table,model:`(${entry.table} migration)`,columns:entry.columns})}errors.push(...derived.errors)}tables.sort((a,b)=>a.table.localeCompare(b.table));const target=options.target??"app",indent=target==="framework"?1:2,body=tables.map((t)=>renderTableEntry(t.table,t.columns,indent)).join(`
18
+ `),content=`${headerFor(target)}${body}
19
+ ${footerFor(target)}`;if(!options.dryRun){const dir=path.dirname(outFile);if(!fs.existsSync(dir))fs.mkdirSync(dir,{recursive:!0});fs.writeFileSync(outFile,content,"utf-8");log.success(`[generate:db-types] wrote ${tables.length} table(s) to ${outFile}`)}return{outFile,tables,errors,content}}export function renderDatabaseTypeFile(tables,target="app"){const body=[...tables].sort((a,b)=>a.table.localeCompare(b.table)).map((t)=>renderTableEntry(t.table,t.columns,target==="framework"?1:2)).join(`
20
+ `);return`${headerFor(target)}${body}
21
+ ${footerFor(target)}`}
@@ -0,0 +1 @@
1
+ export * from './table-traits';
@@ -0,0 +1 @@
1
+ export*from"./table-traits";
@@ -0,0 +1,8 @@
1
+ export declare function generateTraitTableInterfaces(): string;
2
+ export declare function generateTraitRequestTypes(): Promise<string>;
3
+ // Generate request types for each trait interface
4
+ export declare const traitInterfaces: TraitInterface[];
5
+ declare interface TraitInterface {
6
+ name: string
7
+ fields: Record<string, string>
8
+ }
@@ -0,0 +1,135 @@
1
+ import{path}from"@stacksjs/path";function generateMigrationsTableInterface(){return`
2
+ export interface MigrationsTable {
3
+ name: string
4
+ timestamp: string
5
+ }`}function generatePasswordResetsTableInterface(){return`
6
+ export interface PasswordResetsTable {
7
+ email: string
8
+ token: string
9
+ created_at?: string
10
+ }`}function generatePasskeysTableInterface(){return`
11
+ export interface PasskeysTable {
12
+ id?: number
13
+ cred_public_key: string
14
+ user_id: number
15
+ webauthn_user_id: string
16
+ counter: number
17
+ credential_type: string
18
+ device_type: string
19
+ backup_eligible: boolean
20
+ backup_status: boolean
21
+ transports?: string
22
+ created_at?: string
23
+ updated_at?: string
24
+ last_used_at: string
25
+ }`}function generateCommentableInterface(){return`
26
+ export interface CommentablesTable {
27
+ id?: number
28
+ title: string
29
+ body: string
30
+ status: string
31
+ approved_at: number | null
32
+ rejected_at: number | null
33
+ commentables_id: number
34
+ commentables_type: string
35
+ user_id: number | null
36
+ author_name?: string | null
37
+ author_email?: string | null
38
+ created_at?: string
39
+ updated_at?: string | null
40
+ }`}function generateCommentableUpvotesTableInterface(){return`
41
+ export interface CommentableUpvotesTable {
42
+ id?: number
43
+ user_id: number
44
+ upvoteable_id: number
45
+ upvoteable_type: string
46
+ created_at?: string
47
+ }`}function generateCategorizableTableInterface(){return`
48
+ export interface CategorizableTable {
49
+ id?: number
50
+ name: string
51
+ slug: string
52
+ description?: string
53
+ is_active: boolean
54
+ categorizable_type: string
55
+ created_at?: string
56
+ updated_at?: string
57
+ }`}function generateTaggableTableInterface(){return`
58
+ export interface TaggableTable {
59
+ id?: number
60
+ name: string
61
+ slug: string
62
+ description?: string
63
+ is_active: boolean
64
+ taggable_type: string
65
+ created_at?: string
66
+ updated_at?: string
67
+ }`}function generateTaggableModelsTableInterface(){return`
68
+ export interface TaggableModelsTable {
69
+ id?: number
70
+ tag_id: number
71
+ taggable_id: number
72
+ taggable_type: string
73
+ created_at?: string
74
+ updated_at?: string
75
+ }`}function generateCategorizableModelsTableInterface(){return`
76
+ export interface CategorizableModelsTable {
77
+ id?: number
78
+ category_id: number
79
+ categorizable_type: string
80
+ categorizable_id: number
81
+ created_at?: string
82
+ updated_at?: string
83
+ }`}function generateQueryLogsTableInterface(){return`
84
+ export interface QueryLogsTable {
85
+ id?: number
86
+ query: string
87
+ normalized_query: string
88
+ duration: number
89
+ connection: string
90
+ status: 'completed' | 'failed' | 'slow'
91
+ error?: string
92
+ executed_at?: string
93
+ bindings?: string
94
+ trace?: string
95
+ model?: string
96
+ method?: string
97
+ file?: string
98
+ line?: number
99
+ memory_usage?: number
100
+ rows_affected?: number
101
+ transaction_id?: string
102
+ tags?: string
103
+ affected_tables?: string
104
+ indexes_used?: string
105
+ missing_indexes?: string
106
+ explain_plan?: string
107
+ optimization_suggestions?: string
108
+ }`}export function generateTraitTableInterfaces(){return[generateMigrationsTableInterface(),generatePasswordResetsTableInterface(),generatePasskeysTableInterface(),generateCommentableInterface(),generateCommentableUpvotesTableInterface(),generateCategorizableTableInterface(),generateTaggableTableInterface(),generateTaggableModelsTableInterface(),generateCategorizableModelsTableInterface(),generateQueryLogsTableInterface()].join(`
109
+ `)}export const traitInterfaces=[{name:"Migrations",fields:{name:"string",timestamp:"string"}},{name:"PasswordResets",fields:{email:"string",token:"string",created_at:"string"}},{name:"Passkeys",fields:{id:"number",cred_public_key:"string",user_id:"number",webauthn_user_id:"string",counter:"number",credential_type:"string",device_type:"string",backup_eligible:"boolean",backup_status:"boolean",transports:"string",created_at:"string",updated_at:"string",last_used_at:"string"}},{name:"Commentables",fields:{id:"number",title:"string",body:"string",status:"string",approved_at:"number | null",rejected_at:"number | null",commentables_id:"number",commentables_type:"string",user_id:"number | null",created_at:"string",updated_at:"string | null"}},{name:"CommentableUpvotes",fields:{id:"number",user_id:"number",upvoteable_id:"number",upvoteable_type:"string",created_at:"string"}},{name:"Categorizable",fields:{id:"number",name:"string",slug:"string",description:"string",is_active:"boolean",categorizable_type:"string",created_at:"string",updated_at:"string"}},{name:"Taggable",fields:{id:"number",name:"string",slug:"string",description:"string",is_active:"boolean",taggable_type:"string",created_at:"string",updated_at:"string"}},{name:"TaggableModels",fields:{id:"number",tag_id:"number",taggable_id:"number",taggable_type:"string",created_at:"string",updated_at:"string | null"}},{name:"CategorizableModels",fields:{id:"number",category_id:"number",categorizable_id:"number",categorizable_type:"string",created_at:"string",updated_at:"string | null"}}];export async function generateTraitRequestTypes(){let typeString=`import { Request } from '../core/router/src/request'
110
+
111
+ `;typeString+=`interface ValidationRule {
112
+ validate: (value: unknown) => { valid: boolean, errors?: Array<{ message: string }> }
113
+ }
114
+
115
+ `;typeString+=`interface ValidationField {
116
+ rule: ValidationRule
117
+ message: Record<string, string>
118
+ }
119
+
120
+ `;typeString+=`interface CustomAttributes {
121
+ [key: string]: ValidationField
122
+ }
123
+
124
+ `;let importTypesString="";for(let i=0;i<traitInterfaces.length;i++){const trait=traitInterfaces[i];if(!trait)continue;let fieldString="",fieldStringType="";for(const[field,type]of Object.entries(trait.fields))fieldString+=` ${field}: ${type}
125
+ `;fieldStringType+=" get: <T = string>(element: string, defaultValue?: T) => T";const types=`export interface ${trait.name}RequestType extends Request {
126
+ validate(attributes?: CustomAttributes): void
127
+ ${fieldStringType}
128
+ all(): RequestData${trait.name}
129
+ ${fieldString}
130
+ }
131
+
132
+ `;typeString+=`interface RequestData${trait.name} {
133
+ ${fieldString}
134
+ }
135
+ `;typeString+=types;importTypesString+=`${trait.name}RequestType`;if(i<traitInterfaces.length-1)importTypesString+=" | "}typeString+=`export type TraitRequest = ${importTypesString}`;const writer=Bun.file(path.frameworkPath("types/traits.d.ts")).writer();writer.write(typeString);await writer.end();return typeString}
@@ -0,0 +1 @@
1
+
File without changes