From d8004397a4ee29852522b6afa9da02af688989fd Mon Sep 17 00:00:00 2001 From: Simon MacDonald Date: Wed, 7 Jan 2026 15:26:27 -0500 Subject: [PATCH 1/2] feat: add timezone property to scheduled function Signed-off-by: Simon MacDonald --- .../pragmas/populate-lambda/_scheduled.js | 13 +- src/config/pragmas/populate-lambda/index.js | 2 +- src/config/pragmas/validate/_scheduled.js | 19 ++- .../unit/src/config/pragmas/scheduled-test.js | 139 +++++++++++++++++- 4 files changed, 166 insertions(+), 7 deletions(-) diff --git a/src/config/pragmas/populate-lambda/_scheduled.js b/src/config/pragmas/populate-lambda/_scheduled.js index 0211d7a4..94ccbe2e 100644 --- a/src/config/pragmas/populate-lambda/_scheduled.js +++ b/src/config/pragmas/populate-lambda/_scheduled.js @@ -33,12 +33,14 @@ module.exports = function populateScheduled (params) { let { item, errors, plugin } = params let rate = null let cron = null + let timezone = null if (plugin) { let { name, src } = item if (name && src && (item.rate || item.cron)) { if (item.rate) rate = get.rate(item.rate) if (item.cron) cron = get.cron(item.cron) - return { ...item, rate, cron, ...getLambdaDirs(params, { plugin }) } + if (item.timezone) timezone = item.timezone + return { ...item, rate, cron, timezone, ...getLambdaDirs(params, { plugin }) } } errors.push(`Invalid plugin-generated @scheduled item: name: ${name}, rate: ${item.rate}, cron: ${item.cron}, src: ${src}`) return @@ -57,12 +59,12 @@ module.exports = function populateScheduled (params) { if (isCron) cron = get.cron(clean(isCron)) let dirs = getLambdaDirs(params, { name }) - return { name, rate, cron, ...dirs } + return { name, rate, cron, timezone, ...dirs } } else if (is.object(item)) { let name = Object.keys(item)[0] - // Handle rate + cron props + // Handle rate + cron + timezone props if (item[name].rate) { let itemRate = item[name].rate let exp = is.array(itemRate) ? itemRate.join(' ') : itemRate @@ -73,9 +75,12 @@ module.exports = function populateScheduled (params) { let exp = is.array(itemCron) ? itemCron.join(' ') : itemCron cron = get.cron(exp) } + if (item[name].timezone) { + timezone = item[name].timezone + } let dirs = getLambdaDirs(params, { name, customSrc: item[name].src }) - return { name, rate, cron, ...dirs } + return { name, rate, cron, timezone, ...dirs } } errors.push(`Invalid @scheduled item: ${item}`) } diff --git a/src/config/pragmas/populate-lambda/index.js b/src/config/pragmas/populate-lambda/index.js index 702b63ad..f35c3318 100644 --- a/src/config/pragmas/populate-lambda/index.js +++ b/src/config/pragmas/populate-lambda/index.js @@ -170,7 +170,7 @@ function populate (type, pragma, inventory, errors, plugin) { let normalize = path => path.replace(/[\\\/]/g, sep) // Lambda setter plugins can technically return anything, so this ensures everything is tidy -let lambdaProps = [ 'cron', 'method', 'path', 'plugin', 'rate', 'route', 'table', 'type' ] +let lambdaProps = [ 'cron', 'method', 'path', 'plugin', 'rate', 'route', 'table', 'timezone', 'type' ] let configProps = [ ...Object.keys(defaultFunctionConfig()), 'fifo', 'views' ] let getKnownProps = (knownProps, raw = {}) => { let props = knownProps.flatMap(prop => is.defined(raw[prop]) ? [ [ prop, raw[prop] ] ] : []) diff --git a/src/config/pragmas/validate/_scheduled.js b/src/config/pragmas/validate/_scheduled.js index f1ab0c98..4790f586 100644 --- a/src/config/pragmas/validate/_scheduled.js +++ b/src/config/pragmas/validate/_scheduled.js @@ -14,7 +14,7 @@ module.exports = function validateScheduled (scheduled, errors) { unique(scheduled, '@scheduled', errors) scheduled.forEach(schedule => { - let { name, rate, cron } = schedule + let { name, rate, cron, timezone } = schedule regex(name, 'veryLooseName', '@scheduled', errors) // Assume 14 chars are taken up by resource naming in arc/package @@ -22,6 +22,7 @@ module.exports = function validateScheduled (scheduled, errors) { if (cron) validateCron(schedule, errors) if (rate) validateRate(schedule, errors) + if (timezone) validateTimezone(schedule, errors) if (!cron && !rate) errors.push(`Invalid @scheduled item (no cron or rate expression found): ${name}`) if (cron && rate) errors.push(`Invalid @scheduled item (use either cron or rate, not both): ${name}`) @@ -53,6 +54,22 @@ function validateCron (schedule, errors) { if (!year.toString().match(minHrYr)) expErr('year', year) } +function validateTimezone (schedule, errors) { + let { name, timezone } = schedule + if (!is.string(timezone) || !timezone.length) { + errors.push(`Invalid @scheduled item (timezone must be a non-empty string): ${name}`) + } + else { + // AWS EventBridge accepts IANA timezone identifiers (e.g., America/New_York, Europe/London) + try { + Intl.DateTimeFormat(undefined, { timeZone: timezone }) + } + catch { + errors.push(`Invalid @scheduled item (timezone must be a valid IANA timzone identifier): ${name}`) + } + } +} + let singular = [ 'minute', 'hour', 'day' ] let plural = [ 'minutes', 'hours', 'days' ] function validateRate (schedule, errors) { diff --git a/test/unit/src/config/pragmas/scheduled-test.js b/test/unit/src/config/pragmas/scheduled-test.js index 3579bdea..51521024 100644 --- a/test/unit/src/config/pragmas/scheduled-test.js +++ b/test/unit/src/config/pragmas/scheduled-test.js @@ -129,6 +129,45 @@ ${complexValues.join('\n')} }) }) +test('@scheduled population: complex format with timezone', t => { + t.plan(13) + + let tz = 'America/New_York' + let complexValues = [ + `${names[0]} + rate ${rate.expression} + src ${names[0]}/path + timezone ${tz}`, + `${names[1]} + cron ${cron.expression} + src ${names[1]}/path + timezone ${tz}`, + ] + let arc = parse(` +@scheduled +${complexValues.join('\n')} +`) + let scheduled = populateScheduled({ arc, inventory }) + t.assert.equal(scheduled.length, complexValues.length, 'Got correct number of scheduled events back') + names.forEach(name => { + t.assert.ok(scheduled.some(sched => sched.name === name), `Got scheduled event: ${name}`) + }) + scheduled.forEach(sched => { + t.assert.equal(sched.src, join(cwd, `${sched.name}/path`), `Scheduled event configured with correct source dir: ${sched.name}/path`) + t.assert.ok(sched.handlerFile.startsWith(join(cwd, `${sched.name}/path`)), `Handler file is in the correct source dir`) + t.assert.equal(sched.timezone, tz, `Got back correct timezone: ${tz}`) + if (sched.rate) { + t.assert.equal(str(rate), str(sched.rate), `Got back correct rate object: ${str(rate)}`) + t.assert.equal(sched.cron, null, `Got back null cron param`) + } + else if (sched.cron) { + t.assert.equal(str(cron), str(sched.cron), `Got back correct cron object: ${str(cron)}`) + t.assert.equal(sched.rate, null, `Got back null rate param`) + } + else t.assert.fail('Could not find rate or cron expression') + }) +}) + test('@scheduled population: complex format (JSON)', t => { t.plan(11) @@ -165,6 +204,46 @@ test('@scheduled population: complex format (JSON)', t => { }) }) +test('@scheduled population: complex format with timezone (JSON)', t => { + t.plan(13) + + let tz = 'America/New_York' + let json = { + 'scheduled': { + [names[0]]: { + rate: rate.expression, + src: `${names[0]}/path`, + timezone: tz, + }, + [names[1]]: { + cron: cron.expression, + src: `${names[1]}/path`, + timezone: tz, + }, + }, + } + let arc = parse.json(str(json)) + let scheduled = populateScheduled({ arc, inventory }) + t.assert.equal(scheduled.length, Object.keys(json.scheduled).length, 'Got correct number of scheduled events back') + names.forEach(name => { + t.assert.ok(scheduled.some(sched => sched.name === name), `Got scheduled event: ${name}`) + }) + scheduled.forEach(sched => { + t.assert.equal(sched.src, join(cwd, `${sched.name}/path`), `Scheduled event configured with correct source dir: ${sched.name}/path`) + t.assert.ok(sched.handlerFile.startsWith(join(cwd, `${sched.name}/path`)), `Handler file is in the correct source dir`) + t.assert.equal(sched.timezone, tz, `Got back correct timezone: ${tz}`) + if (sched.rate) { + t.assert.equal(str(rate), str(sched.rate), `Got back correct rate object: ${str(rate)}`) + t.assert.equal(sched.cron, null, `Got back null cron param`) + } + else if (sched.cron) { + t.assert.equal(str(cron), str(sched.cron), `Got back correct cron object: ${str(cron)}`) + t.assert.equal(sched.rate, null, `Got back null rate param`) + } + else t.assert.fail('Could not find rate or cron expression') + }) +}) + test('@scheduled population: complex format + fallback to default paths', t => { t.plan(11) @@ -230,8 +309,40 @@ test('@scheduled population: plugin setter', t => { }) }) +test('@scheduled population: plugin setter with timezone', t => { + t.plan(13) + + let tz = 'America/New_York' + let inventory = inventoryDefaults() + let setter = () => [ + { name: names[0], rate: rate.expression, src: join(scheduledDir, names[0]), timezone: tz }, + { name: names[1], cron: cron.expression, src: join(scheduledDir, names[1]), timezone: tz }, + ] + inventory.plugins = setterPluginSetup(setter) + + let scheduled = populateScheduled({ arc: {}, inventory }) + t.assert.equal(scheduled.length, values.length, 'Got correct number of scheduled events back') + names.forEach(name => { + t.assert.ok(scheduled.some(sched => sched.name === name), `Got scheduled event: ${name}`) + }) + scheduled.forEach(sched => { + t.assert.equal(sched.src, join(scheduledDir, sched.name), `Scheduled event configured with correct source dir: ${sched.src}`) + t.assert.ok(sched.handlerFile.startsWith(sched.src), `Handler file is in the correct source dir`) + t.assert.equal(sched.timezone, tz, `Got back correct timezone: ${tz}`) + if (sched.rate) { + t.assert.equal(str(rate), str(sched.rate), `Got back correct rate object: ${str(rate)}`) + t.assert.equal(sched.cron, null, `Got back null cron param`) + } + else if (sched.cron) { + t.assert.equal(str(cron), str(sched.cron), `Got back correct cron object: ${str(cron)}`) + t.assert.equal(sched.rate, null, `Got back null rate param`) + } + else t.assert.fail('Could not find rate or cron expression') + }) +}) + test('@scheduled population: validation errors', t => { - t.plan(27) + t.plan(30) let errors = [] function run (str) { let arc = parse(`@scheduled\n${str}`) @@ -267,6 +378,16 @@ test('@scheduled population: validation errors', t => { run(`hi cron(/ / / / * /)`) run(`hi cron(* * L * L *)`) run(`hi cron(* * W * "#" *)`) + // Valid timezone + run(`hi + rate 1 day + timezone America/New_York`) + run(`hi + rate 1 day + timezone Europe/London`) + run(`hi + rate 1 day + timezone UTC`) t.assert.equal(errors.length, 0, `Valid scheduled did not error`) // Errors @@ -350,6 +471,22 @@ test('@scheduled population: validation errors', t => { run(`hi`) check() + + // Invalid timezone errors + run(`hi + rate 1 day + timezone Gallifrey/Capitol`) + check('Invalid timezone errored') + + run(`hi + rate 1 day + timezone NotATimezone`) + check('Invalid timezone errored') + + run(`hi + rate 1 day + timezone 123`) + check('Invalid timezone errored') }) test('@scheduled population: plugin errors', t => { From cbdb8aff9ee8e34c2ccec4409555af6c22e75139 Mon Sep 17 00:00:00 2001 From: Simon MacDonald Date: Wed, 7 Jan 2026 15:33:20 -0500 Subject: [PATCH 2/2] doc: update changelog Signed-off-by: Simon MacDonald --- changelog.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 52a43861..2413cdc7 100644 --- a/changelog.md +++ b/changelog.md @@ -1,8 +1,24 @@ # Architect Inventory changelog +--- +## [6.1.0] 2026-01-07 + +- Add support for `timezone` property for scheduled functions + +### Changed + +- Update to latest `@architect/utils` + +--- +## [6.0.0] 2025-11-27 + +### Changed + +- Update to latest `@architect/utils` + --- ## [5.0.0] 2025-09-24 - + ### Changed - Breaking change: dropped node 16, 18 support @@ -15,7 +31,7 @@ ### Fixed -- Error out if imported plugin has no discernable plugin API implementation; added in [#83](https://github.com/architect/inventory/pull/83) by @andybee, thanks! +- Error out if imported plugin has no discernable plugin API implementation; added in [#83](https://github.com/architect/inventory/pull/83) by @andybee, thanks! ---