11import errors from '@tryghost/errors' ;
22import tpl from '@tryghost/tpl' ;
3+ import crypto from 'node:crypto' ;
34import ObjectId from 'bson-objectid' ;
45import { dequal } from 'dequal' ;
56import type { DatabaseSync } from 'node:sqlite' ;
@@ -9,12 +10,14 @@ import type {
910 AutomationAction ,
1011 AutomationEdge ,
1112 AutomationSummary ,
13+ AutomationStepToRun ,
1214 AutomationsRepository ,
1315 EditAutomationData ,
1416 Page
1517} from './automations-repository' ;
16- import type { ExclusifyUnion } from 'type-fest' ;
18+ import type { ExclusifyUnion , ReadonlyDeep } from 'type-fest' ;
1719
20+ const LOCK_TIMEOUT_MS = 30 * 60 * 1000 ;
1821const HOUR_MS = 60 * 60 * 1000 ;
1922
2023const messages = {
@@ -61,6 +64,24 @@ interface EdgeRow {
6164 target_action_id : string ;
6265}
6366
67+ type StepRow = {
68+ id : string ;
69+ step_attempts : number ;
70+ automation_run_id : string ;
71+ automation_status : 'inactive' | 'active' ;
72+ member_id : string | null ;
73+ member_email : string ;
74+ action_id : string ;
75+ type : string ;
76+ wait_hours : number | null ;
77+ email_subject : string | null ;
78+ email_lexical : string | null ;
79+ email_sender_name : string | null ;
80+ email_sender_email : string | null ;
81+ email_sender_reply_to : string | null ;
82+ email_design_setting_id : string | null ;
83+ } ;
84+
6485type NextActionRevisionRow = {
6586 automation_id : string ;
6687 action_id : string ;
@@ -141,6 +162,18 @@ export function createFakeDatabaseAutomationsRepository({
141162 const database = getDatabase ( ) ;
142163
143164 return withTransaction ( database , ( ) => trigger ( database , options ) ) ;
165+ } ,
166+
167+ async fetchAndLockSteps ( limit : number ) : Promise < {
168+ steps : AutomationStepToRun [ ] ,
169+ nextStepReadyAt : null ;
170+ } | {
171+ steps : never [ ] ,
172+ nextStepReadyAt : null | Date ;
173+ } > {
174+ const database = getDatabase ( ) ;
175+
176+ return withTransaction ( database , ( ) => fetchAndLockSteps ( database , limit ) ) ;
144177 }
145178 } ;
146179}
@@ -205,6 +238,146 @@ function trigger(database: DatabaseSync, {
205238 } ) ;
206239}
207240
241+ function fetchAndLockSteps ( database : DatabaseSync , limit : number ) : {
242+ steps : AutomationStepToRun [ ] ,
243+ nextStepReadyAt : null ;
244+ } | {
245+ steps : never [ ] ,
246+ nextStepReadyAt : null | Date ;
247+ } {
248+ const now = new Date ( ) ;
249+ const nowString = now . toISOString ( ) ;
250+ const staleLockCutoff = new Date ( now . getTime ( ) - LOCK_TIMEOUT_MS ) ;
251+ const staleLockCutoffString = staleLockCutoff . toISOString ( ) ;
252+ const lockId = crypto . randomUUID ( ) ;
253+
254+ const candidates = database . prepare ( `
255+ SELECT id
256+ FROM automation_run_steps
257+ WHERE status = 'pending'
258+ AND ready_at <= ?
259+ AND (
260+ locked_by IS NULL
261+ OR locked_at < ?
262+ )
263+ ORDER BY ready_at, created_at, id
264+ LIMIT ?
265+ ` ) . all ( nowString , staleLockCutoffString , limit ) as unknown as ReadonlyArray < { id : string } > ;
266+
267+ if ( candidates . length === 0 ) {
268+ return {
269+ steps : [ ] ,
270+ nextStepReadyAt : findNextPendingReadyAt ( database , now )
271+ } ;
272+ }
273+
274+ const candidateIds = candidates . map ( candidate => candidate . id ) ;
275+
276+ const placeholders = candidateIds . map ( ( ) => '?' ) . join ( ',' ) ;
277+ database . prepare ( `
278+ UPDATE automation_run_steps
279+ SET locked_by = ?,
280+ locked_at = ?,
281+ started_at = ?,
282+ updated_at = ?,
283+ step_attempts = step_attempts + 1
284+ WHERE id IN (${ placeholders } )
285+ AND status = 'pending'
286+ AND ready_at <= ?
287+ AND (
288+ locked_by IS NULL
289+ OR locked_at < ?
290+ )
291+ ` ) . run ( lockId , nowString , nowString , nowString , ...candidateIds , nowString , staleLockCutoffString ) ;
292+
293+ const rows = database . prepare ( `
294+ SELECT
295+ step.id AS id,
296+ step.step_attempts AS step_attempts,
297+ step.automation_run_id AS automation_run_id,
298+ automation.status AS automation_status,
299+ run.member_id AS member_id,
300+ run.member_email AS member_email,
301+ action.id AS action_id,
302+ action.type AS type,
303+ revision.wait_hours AS wait_hours,
304+ revision.email_subject AS email_subject,
305+ revision.email_lexical AS email_lexical,
306+ revision.email_sender_name AS email_sender_name,
307+ revision.email_sender_email AS email_sender_email,
308+ revision.email_sender_reply_to AS email_sender_reply_to,
309+ revision.email_design_setting_id AS email_design_setting_id
310+ FROM automation_run_steps step
311+ INNER JOIN automation_runs run ON run.id = step.automation_run_id
312+ INNER JOIN automations automation ON automation.id = run.automation_id
313+ INNER JOIN automation_action_revisions revision ON revision.id = step.automation_action_revision_id
314+ INNER JOIN automation_actions action ON action.id = revision.action_id
315+ WHERE step.locked_by = ?
316+ ORDER BY step.ready_at, step.created_at, step.id
317+ ` ) . all ( lockId ) as unknown as StepRow [ ] ;
318+
319+ if ( rows . length === 0 ) {
320+ return {
321+ steps : [ ] ,
322+ nextStepReadyAt : findNextPendingReadyAt ( database , staleLockCutoff )
323+ } ;
324+ } else {
325+ return {
326+ steps : rows . map ( row => buildStepToRun ( row ) ) ,
327+ nextStepReadyAt : null
328+ } ;
329+ }
330+ }
331+
332+ function findNextPendingReadyAt ( database : DatabaseSync , staleLockCutoff : Readonly < Date > ) : Date | null {
333+ const row = database . prepare ( `
334+ SELECT MIN(ready_at) AS next_ready_at
335+ FROM automation_run_steps
336+ WHERE status = 'pending'
337+ AND (
338+ locked_by IS NULL
339+ OR locked_at < ?
340+ )
341+ ` ) . get ( staleLockCutoff . toISOString ( ) ) as { next_ready_at : string | null } | undefined ;
342+ return row ?. next_ready_at ? new Date ( row . next_ready_at ) : null ;
343+ }
344+
345+ function buildStepToRun ( row : ReadonlyDeep < StepRow > ) : AutomationStepToRun {
346+ const base = {
347+ id : row . id ,
348+ step_attempts : row . step_attempts ,
349+ automation_run_id : row . automation_run_id ,
350+ automation_status : row . automation_status ,
351+ member_id : row . member_id ,
352+ member_email : row . member_email ,
353+ action_id : row . action_id
354+ } ;
355+
356+ switch ( row . type ) {
357+ case 'wait' :
358+ return {
359+ ...base ,
360+ type : 'wait' ,
361+ wait_hours : requireValue ( row , 'wait_hours' )
362+ } ;
363+ case 'send_email' :
364+ return {
365+ ...base ,
366+ type : 'send_email' ,
367+ email_subject : requireValue ( row , 'email_subject' ) ,
368+ email_lexical : requireValue ( row , 'email_lexical' ) ,
369+ email_sender_name : row . email_sender_name ,
370+ email_sender_email : row . email_sender_email ,
371+ email_sender_reply_to : row . email_sender_reply_to ,
372+ email_design_setting_id : row . email_design_setting_id
373+ } ;
374+ default :
375+ throw new errors . InternalServerError ( {
376+ message : `Unexpected action type from database: ${ row . type } `
377+ } ) ;
378+ }
379+ }
380+
208381function findFirstActionRevision ( database : DatabaseSync , memberStatus : 'free' | 'paid' ) : NextActionRevisionRow | null {
209382 const automationSlug : NonNullable < string > = MEMBER_WELCOME_EMAIL_SLUGS [ memberStatus ] ;
210383
@@ -653,12 +826,15 @@ function buildActionPayload(row: ActionRow): AutomationAction {
653826 }
654827}
655828
656- function requireValue < FieldT extends keyof ActionRow > (
657- row : Pick < ActionRow , 'id' | 'type' | FieldT > ,
829+ function requireValue <
830+ RowT extends { id : string , type : string } ,
831+ FieldT extends keyof RowT
832+ > (
833+ row : RowT ,
658834 field : FieldT
659- ) : NonNullable < ActionRow [ FieldT ] > {
835+ ) : NonNullable < RowT [ FieldT ] > {
660836 const value = row [ field ] ;
661- if ( value === null ) {
837+ if ( ( value === null ) || ( value === undefined ) ) {
662838 throw new errors . InternalServerError ( {
663839 message : tpl ( messages . invalidAutomationActionRevision , {
664840 actionId : row . id ,
0 commit comments