@stacksjs/orm 0.75.423 → 0.75.431

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 (2) hide show
  1. package/dist/messaging.js +1 -1
  2. package/package.json +23 -23
package/dist/messaging.js CHANGED
@@ -1 +1 @@
1
- import{createHash}from"node:crypto";import{db,enqueueAfterCommit,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{log}from"@stacksjs/logging";import{emit,getServer}from"@stacksjs/realtime";export class MessagingError extends Error{status;constructor(message,status){super(message);this.status=status}}export function messagingChannel(participantType,userId){return`private-messaging.${createHash("sha256").update(participantType).digest("hex")}.${positive(userId)}`}const MAX_ATTACHMENTS=10,MAX_ATTACHMENTS_LENGTH=4000;function positive(value){if(!Number.isSafeInteger(value)||Number(value)<=0)throw new MessagingError("A positive integer is required",422);return Number(value)}function duplicate(error){const e=error;return e.code==="23505"||e.code==="SQLITE_CONSTRAINT_PRIMARYKEY"||e.code==="SQLITE_CONSTRAINT_UNIQUE"||e.code==="SQLITE_CONSTRAINT"||e.errno===1062}function whole(value,name){if(!Number.isSafeInteger(value)||Number(value)<0)throw new MessagingError(`An attachment ${name} is a whole number of 0 or more`,422);return Number(value)}const ATTACHMENT_KEYS=new Set(["file","type","name","size","width","height"]);function text(value,name){if(typeof value!=="string"||!value.trim()||value.length>255)throw new MessagingError(`An attachment ${name} is 1 to 255 characters`,422);return value.trim()}function attachmentsOf(value){if(value===void 0||value===null)return[];if(!Array.isArray(value))throw new MessagingError("Attachments are a list",422);if(value.length>MAX_ATTACHMENTS)throw new MessagingError(`Send at most ${MAX_ATTACHMENTS} attachments`,422);const list=value.map((entry)=>{const item=entry??{},file=typeof item.file==="string"?item.file.trim():"";if(!file||file.length>255)throw new MessagingError("Each attachment needs a file of 1 to 255 characters",422);const unknown=Object.keys(item).find((key)=>!ATTACHMENT_KEYS.has(key));if(unknown!==void 0)throw new MessagingError(`An attachment cannot carry \`${unknown.slice(0,64)}\`; it may carry ${[...ATTACHMENT_KEYS].join(", ")}`,422);const out={file};if(item.type!==void 0)out.type=text(item.type,"type");if(item.name!==void 0)out.name=text(item.name,"name");if(item.size!==void 0)out.size=whole(item.size,"size");if(item.width!==void 0)out.width=whole(item.width,"width");if(item.height!==void 0)out.height=whole(item.height,"height");return out});if(JSON.stringify(list).length>MAX_ATTACHMENTS_LENGTH)throw new MessagingError("Those attachments are too long to record",422);return list}function storedAttachments(value){if(typeof value!=="string"||!value)return[];try{const parsed=JSON.parse(value);return Array.isArray(parsed)?parsed:[]}catch{return[]}}function message(row){return{id:Number(row.id),conversation_id:String(row.conversation_id),sender_id:Number(row.sender_id),body:String(row.body),attachments:storedAttachments(row.attachments),client_key:String(row.client_key),created_at:parseSqlDateTime(row.created_at).toISOString(),read_at:parseSqlDateTime(row.read_at)?.toISOString()??null}}export function createMessenger(options){const actorId=positive(options.actorId),participantType=options.participantType??"users",maxLength=options.maxLength??4000,pageSize=options.pageSize??50;if(!options.scope||options.scope.length>255||!participantType||participantType.length>255||typeof options.authorize!=="function"||!Number.isSafeInteger(maxLength)||maxLength<1||maxLength>1e5||!Number.isSafeInteger(pageSize)||pageSize<1||pageSize>100)throw new MessagingError("Invalid messaging configuration",422);async function authorize(recipientId){if(options.enabled===!1)throw new MessagingError("Messaging is disabled",403);if(recipientId===actorId||!await options.authorize(actorId,recipientId))throw new MessagingError("Conversation not found",404)}async function conversation(id){if(typeof id!=="string"||!/^[a-f0-9]{64}$/.test(id))throw new MessagingError("Conversation not found",404);const row=await db.selectFrom("chat_conversations").selectAll().where("id","=",id).where("scope","=",options.scope).where("participant_type","=",participantType).executeTakeFirst();if(!row||Number(row.first_id)!==actorId&&Number(row.second_id)!==actorId)throw new MessagingError("Conversation not found",404);const recipientId=Number(row.first_id)===actorId?Number(row.second_id):Number(row.first_id);await authorize(recipientId);return recipientId}async function direct(recipientId){positive(recipientId);await authorize(recipientId);const[first,second]=[actorId,recipientId].sort((a,b)=>a-b),id=createHash("sha256").update(JSON.stringify([options.scope,participantType,first,second])).digest("hex");if(!await db.selectFrom("chat_conversations").select("id").where("id","=",id).executeTakeFirst())try{await db.insertInto("chat_conversations").values({id,scope:options.scope,participant_type:participantType,first_id:first,second_id:second,created_at:sqlDateTime()}).execute()}catch(error){if(!duplicate(error))throw error}return{id,recipient_id:recipientId}}async function list(){if(options.enabled===!1)throw new MessagingError("Messaging is disabled",403);const query=()=>db.selectFrom("chat_conversations").selectAll().where("scope","=",options.scope).where("participant_type","=",participantType),rows=[...await query().where("first_id","=",actorId).execute(),...await query().where("second_id","=",actorId).execute()],results=[];for(const row of rows){const recipientId=Number(row.first_id)===actorId?Number(row.second_id):Number(row.first_id);if(!await options.authorize(actorId,recipientId))continue;const latest=await db.selectFrom("chat_messages").selectAll().where("conversation_id","=",row.id).orderBy("id","desc").limit(1).executeTakeFirst(),unread=await db.selectFrom("chat_messages").select("id").where("conversation_id","=",row.id).where("sender_id","=",recipientId).whereNull("read_at").count();results.push({id:String(row.id),recipient_id:recipientId,last_message:latest?message(latest):null,unread:Number(unread)})}return results.sort((a,b)=>(b.last_message?.id??0)-(a.last_message?.id??0))}async function messages(id,before){await conversation(id);let query=db.selectFrom("chat_messages").selectAll().where("conversation_id","=",id);if(before!==void 0)query=query.where("id","<",positive(before));const rows=await query.orderBy("id","desc").limit(pageSize+1).execute();return{messages:rows.slice(0,pageSize).reverse().map(message),has_more:rows.length>pageSize}}async function send(id,body,clientKey,attachments){const recipientId=await conversation(id),files=attachmentsOf(attachments),text=typeof body==="string"?body.trim():"";if(typeof body!=="string"||!text&&!files.length||text.length>maxLength)throw new MessagingError(`Write a message of 1 to ${maxLength} characters, or send an attachment`,422);if(typeof clientKey!=="string"||!/^[\w-]{1,100}$/.test(clientKey))throw new MessagingError("A valid message retry key is required",422);const stored=files.length?JSON.stringify(files):null,lookup=()=>db.selectFrom("chat_messages").selectAll().where("conversation_id","=",id).where("sender_id","=",actorId).where("client_key","=",clientKey).executeTakeFirst();let row=await lookup(),inserted=!1;if(!row){try{await db.insertInto("chat_messages").values({conversation_id:id,sender_id:actorId,body:text,client_key:clientKey,created_at:sqlDateTime(),read_at:null,...stored?{attachments:stored}:{}}).execute();inserted=!0}catch(error){if(!duplicate(error))throw error}row=await lookup()}if(!row)throw Error("Message could not be saved");if(row.body!==text||String(row.attachments??"")!==String(stored??""))throw new MessagingError("This retry key belongs to a different message",409);const saved=message(row);if(inserted){const deliver=async()=>{notify(recipientId,id,"messaging.sent");try{await options.onMessageSent?.({message:saved,actorId,recipientId,scope:options.scope,participantType})}catch(error){log.warn(`Messaging notification failed: ${error instanceof Error?error.message:"Unknown error"}`)}};if(!enqueueAfterCommit(deliver))await deliver()}return saved}async function markRead(id,throughId){const recipientId=await conversation(id);positive(throughId);if(!await db.selectFrom("chat_messages").select("id").where("conversation_id","=",id).where("id","=",throughId).executeTakeFirst())throw new MessagingError("Message not found",404);const unread=await db.selectFrom("chat_messages").select("id").where("conversation_id","=",id).where("sender_id","=",recipientId).where("id","<=",throughId).whereNull("read_at").limit(1).executeTakeFirst();await db.updateTable("chat_messages").set({read_at:sqlDateTime()}).where("conversation_id","=",id).where("sender_id","=",recipientId).where("id","<=",throughId).whereNull("read_at").execute();if(unread)notify(recipientId,id,"messaging.read")}function notify(recipientId,id,event){if(options.broadcast===!1||!getServer())return;for(const userId of[actorId,recipientId])emit(messagingChannel(participantType,userId),event,{conversation_id:id})}return{direct,list,messages,send,markRead}}export function createMessagingMethods(participantType){return{messenger:(id,options)=>createMessenger({...options,actorId:id,participantType})}}
1
+ import{createHash}from"node:crypto";import{raw}from"bun-query-builder";import{db,enqueueAfterCommit,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{log}from"@stacksjs/logging";import{emit,getServer}from"@stacksjs/realtime";export class MessagingError extends Error{status;constructor(message,status){super(message);this.status=status}}export function messagingChannel(participantType,userId){return`private-messaging.${createHash("sha256").update(participantType).digest("hex")}.${positive(userId)}`}const MAX_ATTACHMENTS=10,MAX_ATTACHMENTS_LENGTH=4000;function positive(value){if(!Number.isSafeInteger(value)||Number(value)<=0)throw new MessagingError("A positive integer is required",422);return Number(value)}function duplicate(error){const e=error;return e.code==="23505"||e.code==="SQLITE_CONSTRAINT_PRIMARYKEY"||e.code==="SQLITE_CONSTRAINT_UNIQUE"||e.code==="SQLITE_CONSTRAINT"||e.errno===1062}function whole(value,name){if(!Number.isSafeInteger(value)||Number(value)<0)throw new MessagingError(`An attachment ${name} is a whole number of 0 or more`,422);return Number(value)}const ATTACHMENT_KEYS=new Set(["file","type","name","size","width","height"]);function text(value,name){if(typeof value!=="string"||!value.trim()||value.length>255)throw new MessagingError(`An attachment ${name} is 1 to 255 characters`,422);return value.trim()}function attachmentsOf(value){if(value===void 0||value===null)return[];if(!Array.isArray(value))throw new MessagingError("Attachments are a list",422);if(value.length>MAX_ATTACHMENTS)throw new MessagingError(`Send at most ${MAX_ATTACHMENTS} attachments`,422);const list=value.map((entry)=>{const item=entry??{},file=typeof item.file==="string"?item.file.trim():"";if(!file||file.length>255)throw new MessagingError("Each attachment needs a file of 1 to 255 characters",422);const unknown=Object.keys(item).find((key)=>!ATTACHMENT_KEYS.has(key));if(unknown!==void 0)throw new MessagingError(`An attachment cannot carry \`${unknown.slice(0,64)}\`; it may carry ${[...ATTACHMENT_KEYS].join(", ")}`,422);const out={file};if(item.type!==void 0)out.type=text(item.type,"type");if(item.name!==void 0)out.name=text(item.name,"name");if(item.size!==void 0)out.size=whole(item.size,"size");if(item.width!==void 0)out.width=whole(item.width,"width");if(item.height!==void 0)out.height=whole(item.height,"height");return out});if(JSON.stringify(list).length>MAX_ATTACHMENTS_LENGTH)throw new MessagingError("Those attachments are too long to record",422);return list}function storedAttachments(value){if(typeof value!=="string"||!value)return[];try{const parsed=JSON.parse(value);return Array.isArray(parsed)?parsed:[]}catch{return[]}}function message(row){return{id:Number(row.id),conversation_id:String(row.conversation_id),sender_id:Number(row.sender_id),body:String(row.body),attachments:storedAttachments(row.attachments),client_key:String(row.client_key),created_at:parseSqlDateTime(row.created_at).toISOString(),read_at:parseSqlDateTime(row.read_at)?.toISOString()??null}}export function createMessenger(options){const actorId=positive(options.actorId),participantType=options.participantType??"users",maxLength=options.maxLength??4000,pageSize=options.pageSize??50;if(!options.scope||options.scope.length>255||!participantType||participantType.length>255||typeof options.authorize!=="function"||!Number.isSafeInteger(maxLength)||maxLength<1||maxLength>1e5||!Number.isSafeInteger(pageSize)||pageSize<1||pageSize>100)throw new MessagingError("Invalid messaging configuration",422);async function authorize(recipientId){if(options.enabled===!1)throw new MessagingError("Messaging is disabled",403);if(recipientId===actorId||!await options.authorize(actorId,recipientId))throw new MessagingError("Conversation not found",404)}async function conversation(id){if(typeof id!=="string"||!/^[a-f0-9]{64}$/.test(id))throw new MessagingError("Conversation not found",404);const row=await db.selectFrom("chat_conversations").selectAll().where("id","=",id).where("scope","=",options.scope).where("participant_type","=",participantType).executeTakeFirst();if(!row||Number(row.first_id)!==actorId&&Number(row.second_id)!==actorId)throw new MessagingError("Conversation not found",404);const recipientId=Number(row.first_id)===actorId?Number(row.second_id):Number(row.first_id);await authorize(recipientId);return recipientId}async function direct(recipientId){positive(recipientId);await authorize(recipientId);const[first,second]=[actorId,recipientId].sort((a,b)=>a-b),id=createHash("sha256").update(JSON.stringify([options.scope,participantType,first,second])).digest("hex");if(!await db.selectFrom("chat_conversations").select("id").where("id","=",id).executeTakeFirst())try{await db.insertInto("chat_conversations").values({id,scope:options.scope,participant_type:participantType,first_id:first,second_id:second,created_at:sqlDateTime()}).execute()}catch(error){if(!duplicate(error))throw error}return{id,recipient_id:recipientId}}async function list(){if(options.enabled===!1)throw new MessagingError("Messaging is disabled",403);const query=()=>db.selectFrom("chat_conversations").selectAll().where("scope","=",options.scope).where("participant_type","=",participantType),threads=[...await query().where("first_id","=",actorId).execute(),...await query().where("second_id","=",actorId).execute()].map((row)=>({id:String(row.id),recipientId:Number(row.first_id)===actorId?Number(row.second_id):Number(row.first_id)})),allowed=await Promise.all(threads.map((thread)=>options.authorize(actorId,thread.recipientId))),visible=threads.filter((_,index)=>allowed[index]);if(!visible.length)return[];const ids=visible.map((thread)=>thread.id),latestIds=(await db.selectFrom("chat_messages").select(["conversation_id"]).selectRaw(raw`MAX(id) AS id`).where("conversation_id","in",ids).groupBy("conversation_id").execute()).map((row)=>Number(row.id)).filter((id)=>Number.isSafeInteger(id)&&id>0),latest=latestIds.length?await db.selectFrom("chat_messages").selectAll().where("id","in",latestIds).execute():[],latestByThread=new Map(latest.map((row)=>[String(row.conversation_id),row])),recipientOf=new Map(visible.map((thread)=>[thread.id,thread.recipientId])),unreadRows=await db.selectFrom("chat_messages").select(["conversation_id","sender_id"]).selectRaw(raw`COUNT(*) AS unread`).where("conversation_id","in",ids).whereNull("read_at").groupBy("conversation_id","sender_id").execute(),unread=new Map;for(const row of unreadRows){const thread=String(row.conversation_id);if(Number(row.sender_id)===recipientOf.get(thread))unread.set(thread,Number(row.unread))}return visible.map((thread)=>{const row=latestByThread.get(thread.id);return{id:thread.id,recipient_id:thread.recipientId,last_message:row?message(row):null,unread:unread.get(thread.id)??0}}).sort((a,b)=>(b.last_message?.id??0)-(a.last_message?.id??0))}async function messages(id,before){await conversation(id);let query=db.selectFrom("chat_messages").selectAll().where("conversation_id","=",id);if(before!==void 0)query=query.where("id","<",positive(before));const rows=await query.orderBy("id","desc").limit(pageSize+1).execute();return{messages:rows.slice(0,pageSize).reverse().map(message),has_more:rows.length>pageSize}}async function send(id,body,clientKey,attachments){const recipientId=await conversation(id),files=attachmentsOf(attachments),text=typeof body==="string"?body.trim():"";if(typeof body!=="string"||!text&&!files.length||text.length>maxLength)throw new MessagingError(`Write a message of 1 to ${maxLength} characters, or send an attachment`,422);if(typeof clientKey!=="string"||!/^[\w-]{1,100}$/.test(clientKey))throw new MessagingError("A valid message retry key is required",422);const stored=files.length?JSON.stringify(files):null,lookup=()=>db.selectFrom("chat_messages").selectAll().where("conversation_id","=",id).where("sender_id","=",actorId).where("client_key","=",clientKey).executeTakeFirst();let row=await lookup(),inserted=!1;if(!row){try{await db.insertInto("chat_messages").values({conversation_id:id,sender_id:actorId,body:text,client_key:clientKey,created_at:sqlDateTime(),read_at:null,...stored?{attachments:stored}:{}}).execute();inserted=!0}catch(error){if(!duplicate(error))throw error}row=await lookup()}if(!row)throw Error("Message could not be saved");if(row.body!==text||String(row.attachments??"")!==String(stored??""))throw new MessagingError("This retry key belongs to a different message",409);const saved=message(row);if(inserted){const deliver=async()=>{notify(recipientId,id,"messaging.sent");try{await options.onMessageSent?.({message:saved,actorId,recipientId,scope:options.scope,participantType})}catch(error){log.warn(`Messaging notification failed: ${error instanceof Error?error.message:"Unknown error"}`)}};if(!enqueueAfterCommit(deliver))await deliver()}return saved}async function markRead(id,throughId){const recipientId=await conversation(id);positive(throughId);if(!await db.selectFrom("chat_messages").select("id").where("conversation_id","=",id).where("id","=",throughId).executeTakeFirst())throw new MessagingError("Message not found",404);const unread=await db.selectFrom("chat_messages").select("id").where("conversation_id","=",id).where("sender_id","=",recipientId).where("id","<=",throughId).whereNull("read_at").limit(1).executeTakeFirst();await db.updateTable("chat_messages").set({read_at:sqlDateTime()}).where("conversation_id","=",id).where("sender_id","=",recipientId).where("id","<=",throughId).whereNull("read_at").execute();if(unread)notify(recipientId,id,"messaging.read")}function notify(recipientId,id,event){if(options.broadcast===!1||!getServer())return;for(const userId of[actorId,recipientId])emit(messagingChannel(participantType,userId),event,{conversation_id:id})}return{direct,list,messages,send,markRead}}export function createMessagingMethods(participantType){return{messenger:(id,options)=>createMessenger({...options,actorId:id,participantType})}}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/orm",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.423",
5
+ "version": "0.75.431",
6
6
  "description": "The Stacks ORM integration",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -63,31 +63,31 @@
63
63
  "prepublishOnly": "bun run build"
64
64
  },
65
65
  "dependencies": {
66
- "@stacksjs/cache": "0.75.423",
67
- "@stacksjs/cli": "0.75.423",
68
- "@stacksjs/config": "0.75.423",
69
- "@stacksjs/database": "0.75.423",
70
- "@stacksjs/env": "0.75.423",
71
- "@stacksjs/error-handling": "0.75.423",
72
- "@stacksjs/events": "0.75.423",
73
- "@stacksjs/logging": "0.75.423",
74
- "@stacksjs/model-meta": "0.75.423",
75
- "@stacksjs/pagination": "0.75.423",
76
- "@stacksjs/path": "0.75.423",
77
- "@stacksjs/query-builder": "0.75.423",
78
- "@stacksjs/realtime": "0.75.423",
79
- "@stacksjs/router": "0.75.423",
80
- "@stacksjs/storage": "0.75.423",
81
- "@stacksjs/strings": "0.75.423",
66
+ "@stacksjs/cache": "0.75.431",
67
+ "@stacksjs/cli": "0.75.431",
68
+ "@stacksjs/config": "0.75.431",
69
+ "@stacksjs/database": "0.75.431",
70
+ "@stacksjs/env": "0.75.431",
71
+ "@stacksjs/error-handling": "0.75.431",
72
+ "@stacksjs/events": "0.75.431",
73
+ "@stacksjs/logging": "0.75.431",
74
+ "@stacksjs/model-meta": "0.75.431",
75
+ "@stacksjs/pagination": "0.75.431",
76
+ "@stacksjs/path": "0.75.431",
77
+ "@stacksjs/query-builder": "0.75.431",
78
+ "@stacksjs/realtime": "0.75.431",
79
+ "@stacksjs/router": "0.75.431",
80
+ "@stacksjs/storage": "0.75.431",
81
+ "@stacksjs/strings": "0.75.431",
82
82
  "@stacksjs/ts-validation": "^0.5.6",
83
- "@stacksjs/validation": "0.75.423",
83
+ "@stacksjs/validation": "0.75.431",
84
84
  "bun-query-builder": "^0.3.32"
85
85
  },
86
86
  "peerDependencies": {
87
- "@stacksjs/auth": "0.75.423",
88
- "@stacksjs/payments": "0.75.423",
89
- "@stacksjs/queue": "0.75.423",
90
- "@stacksjs/search-engine": "0.75.423",
87
+ "@stacksjs/auth": "0.75.431",
88
+ "@stacksjs/payments": "0.75.431",
89
+ "@stacksjs/queue": "0.75.431",
90
+ "@stacksjs/search-engine": "0.75.431",
91
91
  "stripe": "23.0.0"
92
92
  },
93
93
  "peerDependenciesMeta": {
@@ -108,7 +108,7 @@
108
108
  }
109
109
  },
110
110
  "devDependencies": {
111
- "@stacksjs/types": "0.75.423",
111
+ "@stacksjs/types": "0.75.431",
112
112
  "better-dx": "^0.2.24",
113
113
  "stripe": "23.0.0"
114
114
  }