Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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!

---

Expand Down
13 changes: 9 additions & 4 deletions src/config/pragmas/populate-lambda/_scheduled.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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}`)
}
2 changes: 1 addition & 1 deletion src/config/pragmas/populate-lambda/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] ] ] : [])
Expand Down
19 changes: 18 additions & 1 deletion src/config/pragmas/validate/_scheduled.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ 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
size(name, 1, 242, '@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}`)
Expand Down Expand Up @@ -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) {
Expand Down
139 changes: 138 additions & 1 deletion test/unit/src/config/pragmas/scheduled-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down