@@ -80,12 +80,17 @@ import {
8080 type EmailAttachmentStore ,
8181} from './attachment-storage.js' ;
8282import { withContentReclaimed } from './sys-email-payload.js' ;
83+ import { EMAIL_LOG_OBJECTS , TENANT_EMAIL_OBJECT , emailLogObjectOfJob } from './email-log-objects.js' ;
8384
8485/** System context — reclamation is platform bookkeeping, not a user query. */
8586const SYSTEM_CTX = { isSystem : true , positions : [ ] , permissions : [ ] } as const ;
8687
87- /** Backing object; a constant so the query and the log lines agree. */
88- export const RECLAIM_OBJECT = 'sys_email' ;
88+ /**
89+ * Backing object of a job that names none — every job an 18.0 process
90+ * published, and every `sys_email` job since. A `sys_identity_email` job
91+ * names its log in the payload (`email-log-objects.ts`).
92+ */
93+ export const RECLAIM_OBJECT = TENANT_EMAIL_OBJECT ;
8994
9095/**
9196 * `sys_email.status` values after which content is never needed again.
@@ -188,18 +193,31 @@ export async function reclaimAttachmentContent(
188193
189194 if ( keys . length === 0 ) return { kind : 'noop' , rowId } ;
190195
196+ // The log the row lives in. A payload naming something else is refused,
197+ // not guessed: reading the wrong log finds no row, and "no row" is the
198+ // branch below that DELETES the content.
199+ const object = emailLogObjectOfJob ( payload ?. object ) ;
200+ if ( ! object ) {
201+ // Stalled, never re-armed: no later run can read a log the job does not name.
202+ return rearmOrStall (
203+ { ...opts , rearm : undefined } , rowId , 'mail' , graceMs ,
204+ `the job names '${ String ( payload ?. object ) } ', which is not a mail log (${ EMAIL_LOG_OBJECTS . join ( ', ' ) } ), `
205+ + 'so the row its content belongs to cannot be read' ,
206+ ) ;
207+ }
208+
191209 if ( ! opts . store ) {
192210 // The capability that HOLDS the bytes is not mounted on this process.
193211 // Deleting is impossible; re-arm so a process that has it can finish the
194212 // job, and say so if nothing can.
195213 return rearmOrStall (
196- opts , rowId , graceMs ,
214+ opts , rowId , object , graceMs ,
197215 'the storage capability is not mounted on the process running the reclaim job, so the content '
198216 + 'cannot be deleted here' ,
199217 ) ;
200218 }
201219
202- const found = await opts . engine . find ( RECLAIM_OBJECT , {
220+ const found = await opts . engine . find ( object , {
203221 where : { id : rowId } ,
204222 limit : 1 ,
205223 context : SYSTEM_CTX ,
@@ -211,9 +229,9 @@ export async function reclaimAttachmentContent(
211229 // them from the payload's own key list is what makes row deletion incapable
212230 // of orphaning content — see the module header.
213231 if ( ! row ) {
214- await deleteOrThrow ( opts . store , keys , rowId , ' the sys_email row no longer exists' ) ;
232+ await deleteOrThrow ( opts . store , keys , rowId , object , ` the ${ object } row no longer exists` ) ;
215233 opts . logger ?. info ?.(
216- `EmailServicePlugin: reclaimed ${ keys . length } out-of-row attachment object(s) for sys_email row `
234+ `EmailServicePlugin: reclaimed ${ keys . length } out-of-row attachment object(s) for ${ object } row `
217235 + `'${ rowId } ', which no longer exists — the content had no remaining reference.` ,
218236 ) ;
219237 return { kind : 'orphan-reclaimed' , rowId, keys } ;
@@ -223,8 +241,8 @@ export async function reclaimAttachmentContent(
223241 const status = String ( row . status ?? '' ) ;
224242 if ( ! TERMINAL_EMAIL_STATUSES . includes ( status ) ) {
225243 return rearmOrStall (
226- opts , rowId , graceMs ,
227- `the sys_email row is still at '${ status || '(unset)' } ', so its content may still be needed for delivery` ,
244+ opts , rowId , object , graceMs ,
245+ `the ${ object } row is still at '${ status || '(unset)' } ', so its content may still be needed for delivery` ,
228246 ) ;
229247 }
230248
@@ -236,8 +254,8 @@ export async function reclaimAttachmentContent(
236254 const age = msSinceLastChange ( row , now ) ;
237255 if ( age !== undefined && age < graceMs ) {
238256 return rearmOrStall (
239- opts , rowId , graceMs - age ,
240- `the sys_email row was last updated ${ Math . round ( age / 1000 ) } s ago, inside the `
257+ opts , rowId , object , graceMs - age ,
258+ `the ${ object } row was last updated ${ Math . round ( age / 1000 ) } s ago, inside the `
241259 + `${ Math . round ( graceMs / 1000 ) } s reclaim grace window` ,
242260 ) ;
243261 }
@@ -248,19 +266,19 @@ export async function reclaimAttachmentContent(
248266 // very function that exists to prevent them. This order's failure mode is a
249267 // row that names a key which 404s, which is loud on read (the decoder
250268 // verifies) and fixed by the job's next attempt.
251- await deleteOrThrow ( opts . store , keys , rowId , `the sys_email row is terminal ('${ status } ')` ) ;
269+ await deleteOrThrow ( opts . store , keys , rowId , object , `the ${ object } row is terminal ('${ status } ')` ) ;
252270
253271 const rewritten = withContentReclaimed ( row . attachments_json , new Date ( now ) . toISOString ( ) ) ;
254272 if ( rewritten !== undefined ) {
255273 await opts . engine . update (
256- RECLAIM_OBJECT ,
274+ object ,
257275 { id : rowId , attachments_json : rewritten } ,
258276 { context : SYSTEM_CTX } ,
259277 ) ;
260278 }
261279
262280 opts . logger ?. info ?.(
263- `EmailServicePlugin: reclaimed ${ keys . length } out-of-row attachment object(s) for sys_email row `
281+ `EmailServicePlugin: reclaimed ${ keys . length } out-of-row attachment object(s) for ${ object } row `
264282 + `'${ rowId } ' (status '${ status } '). The row keeps filename/contentType/size/hash as audit evidence; only `
265283 + 'the delivery content was deleted.' ,
266284 ) ;
@@ -272,12 +290,13 @@ async function deleteOrThrow(
272290 store : EmailAttachmentStore ,
273291 keys : string [ ] ,
274292 rowId : string ,
293+ object : string ,
275294 because : string ,
276295) : Promise < void > {
277296 const { failed } = await deleteAttachmentKeys ( store , keys ) ;
278297 if ( failed . length === 0 ) return ;
279298 throw new Error (
280- `reclaiming attachment content for sys_email row '${ rowId } ' (${ because } ) could not delete `
299+ `reclaiming attachment content for ${ object } row '${ rowId } ' (${ because } ) could not delete `
281300 + `${ failed . length } of ${ keys . length } storage object(s): `
282301 + failed . map ( ( f ) => `'${ f . key } ' (${ f . error } )` ) . join ( '; ' )
283302 + '. Those bytes are still billed and no longer needed; the reclaim job will retry.' ,
@@ -288,6 +307,7 @@ async function deleteOrThrow(
288307async function rearmOrStall (
289308 opts : ReclaimAttachmentContentOptions ,
290309 rowId : string ,
310+ object : string ,
291311 delayMs : number ,
292312 reason : string ,
293313) : Promise < AttachmentReclaimOutcome > {
@@ -301,7 +321,7 @@ async function rearmOrStall(
301321 // for. That is the durability/consequence class AGENTS.md pins at `error`,
302322 // and the line owes both the consequence and the fix.
303323 opts . logger ?. error ?.(
304- `EmailServicePlugin: out-of-row attachment content for sys_email row '${ rowId } ' could NOT be reclaimed and `
324+ `EmailServicePlugin: out-of-row attachment content for ${ object } row '${ rowId } ' could NOT be reclaimed and `
305325 + `could NOT be rescheduled — ${ reason } . Those storage objects will now stay in the backend forever unless `
306326 + 'they are deleted by hand. Fix: keep the durable queue service (@objectstack/service-queue over an '
307327 + 'ObjectQL engine) and the storage capability (@objectstack/service-storage) mounted on the process '
0 commit comments