postgres.mjs 4.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
  1. export function createPostgresStore(pg, definitions, crypto) {
  2. const checkTable = name => { if (!Object.hasOwn(definitions,name)) throw new Error('TABLE_NOT_ALLOWED'); return name; };
  3. const checkField = (name,key) => { checkTable(name); if (!Object.hasOwn(definitions[name],key)) throw new Error('FIELD_NOT_ALLOWED'); return key; };
  4. const decode = (name,row) => {
  5. if (!row) return null;
  6. const out={};
  7. for (const [k,v] of Object.entries(row)) {
  8. if (k.startsWith('_') || k==='ACL' || !Object.hasOwn(definitions[name],k)) continue;
  9. const type=definitions[name][k];
  10. out[k]=v instanceof Date?v.toISOString():v;
  11. if(v!=null&&type.type==='Pointer')out[k]={__type:'Pointer',className:type.targetClass,objectId:v};
  12. if(v!=null&&type.type==='File')out[k]={__type:'File',name:v};
  13. }
  14. return out;
  15. };
  16. function encode(name,key,value){
  17. const {type}=definitions[name][checkField(name,key)];
  18. if(value===null)return{value:null,cast:''};
  19. if(type==='Pointer')return{value:value.objectId,cast:''};
  20. if(type==='File')return{value:value.name,cast:''};
  21. if(type==='Date')return{value:value instanceof Date?value.toISOString():(value?.iso||value),cast:'::timestamptz'};
  22. if(type==='Object'||type==='Array')return{value:JSON.stringify(value),cast:'::jsonb'};
  23. return{value,cast:''};
  24. }
  25. function make(connection){
  26. const store={
  27. async transaction(fn){return connection.tx(t=>fn(make(t)));},
  28. async lock(key){await connection.one('SELECT pg_advisory_xact_lock(hashtextextended($1,0))',[key]);},
  29. async get(name,id,{forUpdate=false}={}){
  30. checkTable(name);const row=await connection.oneOrNone('SELECT * FROM $1:name WHERE "objectId"=$2'+(forUpdate?' FOR UPDATE':''),[name,id]);return decode(name,row);
  31. },
  32. async list(name,{where={},afterId=null,limit=200,search=null,forUpdate=false}={}){
  33. checkTable(name);const values=[name],conditions=[];
  34. for(const[k,v]of Object.entries(where)){checkField(name,k);values.push(k);const key='$'+values.length+':name';
  35. if(v===null)conditions.push(key+' IS NULL');else{const e=encode(name,k,v);values.push(e.value);conditions.push(key+'=$'+values.length+e.cast);}}
  36. if(afterId){values.push(afterId);conditions.push('"objectId">$'+values.length);}
  37. if(search){const fields=['searchText','title','name','rawText','content','text'].filter(k=>definitions[name][k]?.type==='String');
  38. if(fields.length){values.push('%'+search.replace(/[\\%_]/g,'\\$&')+'%');const val='$'+values.length;conditions.push('('+fields.map(k=>{values.push(k);return'$'+values.length+':name ILIKE '+val;}).join(' OR ')+')');}}
  39. values.push(limit);const sql='SELECT * FROM $1:name'+(conditions.length?' WHERE '+conditions.join(' AND '):'')+' ORDER BY "objectId" LIMIT $'+values.length+(forUpdate?' FOR UPDATE':'');
  40. return(await connection.any(sql,values)).map(r=>decode(name,r));
  41. },
  42. async count(name,where={}){return(await store.list(name,{where,limit:10001})).length;},
  43. async create(name,data){
  44. checkTable(name);const now=new Date().toISOString();const item={...data,objectId:crypto.randomBytes(9).toString('base64url').slice(0,10),createdAt:now,updatedAt:now};
  45. const values=[name],columns=[],placeholders=[];
  46. for(const[k,v]of Object.entries(item)){if(v===undefined)continue;const e=encode(name,k,v);values.push(k);columns.push('$'+values.length+':name');values.push(e.value);placeholders.push('$'+values.length+e.cast);}
  47. // Objects are private by default; read and write access goes through authenticated functions.
  48. columns.push('"_rperm"','"_wperm"');placeholders.push("ARRAY[]::text[]","ARRAY[]::text[]");
  49. const row=await connection.one('INSERT INTO $1:name ('+columns.join(',')+') VALUES ('+placeholders.join(',')+') RETURNING *',values);return decode(name,row);
  50. },
  51. async update(name,id,changes,{expectedUpdatedAt}={}){
  52. checkTable(name);const data={...changes,updatedAt:new Date().toISOString()};const values=[name,id],sets=[];
  53. for(const[k,v]of Object.entries(data)){if(v===undefined)continue;const e=encode(name,k,v);values.push(k);const key='$'+values.length+':name';values.push(e.value);const value='$'+values.length+e.cast;sets.push(key+'='+(k==='updatedAt'?'GREATEST('+value+',"updatedAt" + interval \'1 millisecond\')':value));}
  54. let condition='"objectId"=$2';if(expectedUpdatedAt){values.push(expectedUpdatedAt);condition+=' AND "updatedAt"=$'+values.length+'::timestamptz';}
  55. const row=await connection.oneOrNone('UPDATE $1:name SET '+sets.join(',')+' WHERE '+condition+' RETURNING *',values);
  56. if(!row){const e=new Error('VERSION_CONFLICT');e.code='VERSION_CONFLICT';throw e;}return decode(name,row);
  57. },
  58. async remove(name,id){checkTable(name);await connection.none('DELETE FROM $1:name WHERE "objectId"=$2',[name,id]);},
  59. };return store;
  60. }
  61. return make(pg);
  62. }