- Added per-source sync difference previews, retry for individual failed jobs, and owner-only batch retry of failed jobs.
- Added optional per-source attachment archive age: cached files are replaced with their original remote links after the configured period.
- Added opt-in Discord/ntfy alerts for exhausted sync retries and stale signed webhooks, with per-event rate limiting.
- Added safe signed Memos webhook rotation: create the replacement first, then remove the previous remote webhook before committing the new credentials.
db.prepare("UPDATE sources SET name=?,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(name,name,avatarUrl,id);
}catch(connectionError){constmessage=connectionErrorinstanceofError?connectionError.message:"Connection failed";db.prepare("UPDATE sources SET last_connection_error=? WHERE id=?").run(message,id);throwconnectionError;}
}elseif(action==="retry-failed-jobs"){
if(!owner)thrownewError("Only the owner can retry all failed jobs");db.prepare("UPDATE sync_jobs SET status='queued',attempts=0,last_error=NULL,started_at=NULL,finished_at=NULL,run_after=CURRENT_TIMESTAMP WHERE source_id=? AND status='failed'").run(id);
}elseif(action==="leave"){
if(owner)thrownewError("Transfer ownership or delete the source before leaving");
db.prepare("DELETE FROM source_members WHERE source_id=? AND user_id=?").run(id,user.id);
exportasyncfunctionGET(_request: Request,{params}:{params: Promise<{id: string}>}){try{constuser=awaitrequireUser();const{id: rawId}=awaitparams;constid=Number(rawId);constsource=db.prepare("SELECT s.* FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.integration_type='memos'").get(id,user.id)asany;if(!source)returnNextResponse.json({error:"Not found"},{status: 404});consttoken=decrypt(source.token_encrypted);constidentity=awaitgetMemosIdentity(source.base_url,token);construles={creator: identity.name,tags: JSON.parse(source.sync_tags_json||"[]"),from:source.sync_from,to: source.sync_to,attachmentMode: source.sync_attachment_mode};constremote=awaitlistMemos(source.base_url,token,rules,{pageSize: 100});constlocal=newSet((db.prepare("SELECT remote_memo_name FROM posts WHERE source_id=? AND origin='memos'").all(id)as{remote_memo_name: string}[]).map((row)=>row.remote_memo_name));constadded=remote.memos.filter((memo)=>!local.has(memo.name)).length;returnNextResponse.json({inspected: remote.memos.length,added,existing: remote.memos.length-added,hasMore: Boolean(remote.nextPageToken)});}catch(error){returnNextResponse.json({error: errorinstanceofError?error.message:"preview"},{status: 400});}}
exportasyncfunctionPOST(request: Request,{params}:{params: Promise<{id: string}>}){try{requireSameOrigin(request);constuser=awaitrequireUser();const{id: rawId}=awaitparams;constid=Number(rawId);constjob=db.prepare("SELECT j.id FROM sync_jobs j JOIN source_members sm ON sm.source_id=j.source_id WHERE j.id=? AND sm.user_id=? AND j.status='failed'").get(id,user.id);if(!job)thrownewError("Failed job not found");db.prepare("UPDATE sync_jobs SET status='queued',attempts=0,last_error=NULL,started_at=NULL,finished_at=NULL,run_after=CURRENT_TIMESTAMP WHERE id=?").run(id);returnNextResponse.redirect(externalUrl(request,"/dashboard?sync=queued"));}catch(error){returnNextResponse.redirect(externalUrl(request,"/dashboard?error="+encodeURIComponent(errorinstanceofError?error.message:"retry")));}}
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.