30 lines
3.5 KiB
JavaScript

import pg from 'pg';
export const schema=`
CREATE TABLE IF NOT EXISTS character_sheets(owner text NOT NULL,id text NOT NULL,character jsonb,revision integer NOT NULL DEFAULT 1,deleted boolean NOT NULL DEFAULT false,PRIMARY KEY(owner,id));
CREATE TABLE IF NOT EXISTS mcp_clients(id text PRIMARY KEY,metadata jsonb NOT NULL);
CREATE TABLE IF NOT EXISTS mcp_grants(id text PRIMARY KEY,payload jsonb NOT NULL,expires_at bigint NOT NULL,revoked boolean NOT NULL DEFAULT false);
CREATE TABLE IF NOT EXISTS mcp_codes(id text PRIMARY KEY,payload jsonb NOT NULL,expires_at bigint NOT NULL);
`;
export class Conflict extends Error{constructor(){super('Character changed elsewhere. Reload its current revision before saving.');this.status=409}}
export class Storage{
constructor(pool){this.pool=pool}
async init(){await this.pool.query(schema)}
async list(owner){return (await this.pool.query('SELECT id,character,revision,deleted FROM character_sheets WHERE owner=$1 ORDER BY id',[owner])).rows}
async get(owner,id){return (await this.pool.query('SELECT id,character,revision,deleted FROM character_sheets WHERE owner=$1 AND id=$2',[owner,id])).rows[0]}
async put(owner,id,character,revision,deleted=false){
// Preserve additive collections omitted by older clients, under the same account/revision guard.
if(character&&!deleted&&revision>0&&(character.inventoryItems===undefined||character.currency===undefined||character.companions===undefined)){const prior=await this.get(owner,id);for(const key of ['inventoryItems','currency','companions'])if(character[key]===undefined&&prior?.character?.[key]!==undefined)character={...character,[key]:prior.character[key]}}
const result=revision===0?await this.pool.query('INSERT INTO character_sheets(owner,id,character,deleted) VALUES($1,$2,$3,$4) ON CONFLICT(owner,id) DO NOTHING RETURNING id,character,revision,deleted',[owner,id,character,deleted]):await this.pool.query('UPDATE character_sheets SET character=$3,deleted=$4,revision=revision+1 WHERE owner=$1 AND id=$2 AND revision=$5 RETURNING id,character,revision,deleted',[owner,id,character,deleted,revision]);
if(!result.rows[0])throw new Conflict();return result.rows[0];
}
async client(id){return (await this.pool.query('SELECT metadata FROM mcp_clients WHERE id=$1',[id])).rows[0]?.metadata}
async register(id,metadata){await this.pool.query('INSERT INTO mcp_clients(id,metadata) VALUES($1,$2)',[id,metadata]);return metadata}
async grant(id,payload,expires){await this.pool.query('INSERT INTO mcp_grants(id,payload,expires_at) VALUES($1,$2,$3)',[id,payload,expires])}
async readGrant(id){const r=(await this.pool.query('SELECT payload,expires_at,revoked FROM mcp_grants WHERE id=$1',[id])).rows[0];return r&&!r.revoked&&Number(r.expires_at)>Date.now()?r.payload:undefined}
async revoke(id){await this.pool.query('UPDATE mcp_grants SET revoked=true WHERE id=$1',[id])}
async code(id,payload,seconds=300){await this.pool.query('DELETE FROM mcp_codes WHERE expires_at<$1',[Date.now()]);await this.pool.query('INSERT INTO mcp_codes(id,payload,expires_at) VALUES($1,$2,$3)',[id,payload,Date.now()+seconds*1000])}
async consume(id){const r=await this.pool.query('DELETE FROM mcp_codes WHERE id=$1 RETURNING payload,expires_at',[id]);const row=r.rows[0];return row&&Number(row.expires_at)>Date.now()?row.payload:undefined}
}
export async function productionStorage(){if(!process.env.DATABASE_URL)throw new Error('DATABASE_URL is required');const storage=new Storage(new pg.Pool({connectionString:process.env.DATABASE_URL,max:5}));await storage.init();return storage}