create-qpq-app 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/package.json +2 -2
  2. package/template/apps/qpqjs/services/test/service/src/infrastructure.ts +3 -0
  3. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askExecuteSmokeRun.ts +5 -3
  4. package/template/apps/qpqjs/services/test/service/src/smoke/tests/SmokeTestDefinition.ts +7 -1
  5. package/template/apps/qpqjs/services/test/service/src/smoke/tests/schedule/askRunScheduleTest.ts +88 -0
  6. package/template/apps/qpqjs/services/test/service/src/smoke/tests/schedule/index.ts +1 -0
  7. package/template/apps/qpqjs/services/test/service/src/smoke/tests/smokeTestRegistry.ts +2 -0
  8. package/template/apps/qpqjs/services/test/service/src/tick/config/defineTick.ts +50 -0
  9. package/template/apps/qpqjs/services/test/service/src/tick/config/index.ts +1 -0
  10. package/template/apps/qpqjs/services/test/service/src/tick/constants/index.ts +2 -0
  11. package/template/apps/qpqjs/services/test/service/src/tick/constants/scheduleTickName.ts +3 -0
  12. package/template/apps/qpqjs/services/test/service/src/tick/constants/scheduleTickStore.ts +3 -0
  13. package/template/apps/qpqjs/services/test/service/src/tick/entry/kvsStream/index.ts +1 -0
  14. package/template/apps/qpqjs/services/test/service/src/tick/entry/kvsStream/onScheduleTickStream.ts +41 -0
  15. package/template/apps/qpqjs/services/test/service/src/tick/entry/schedule/index.ts +1 -0
  16. package/template/apps/qpqjs/services/test/service/src/tick/entry/schedule/onTick.ts +45 -0
  17. package/template/apps/qpqjs/services/test/service/src/tick/index.ts +1 -0
  18. package/template/apps/qpqjs/services/test/service/src/tick/models/ScheduleTickRecord.ts +17 -0
  19. package/template/apps/qpqjs/services/test/service/src/tick/models/index.ts +1 -0
  20. package/template/docusaurus/docs/config/core/recurring-schedule.md +31 -28
  21. package/template/scripts/deployedSmoke.mjs +7 -80
  22. package/template/scripts/localSmoke.mjs +188 -0
  23. package/template/scripts/smoke/runSmokeRun.mjs +100 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-qpq-app",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Scaffold a new quidproquo app: npx create-qpq-app my-app",
5
5
  "main": "./lib/commonjs/index.js",
6
6
  "module": "./lib/esm/index.js",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^22.13.13",
58
- "quidproquo-tsconfig": "0.1.18"
58
+ "quidproquo-tsconfig": "0.1.19"
59
59
  },
60
60
  "bin": {
61
61
  "create-qpq-app": "./lib/commonjs/bin/createQpqApp.js"
@@ -4,6 +4,7 @@ import { QpqjsServiceEnum } from '@qpqjs/constants';
4
4
  import { defineQpqjsService } from '@qpqjs/service-utils';
5
5
 
6
6
  import { defineSmoke } from './smoke/config/defineSmoke';
7
+ import { defineTick } from './tick/config/defineTick';
7
8
 
8
9
  export default [
9
10
  defineDevServerOptions({ port: 3083 }),
@@ -16,4 +17,6 @@ export default [
16
17
  ),
17
18
 
18
19
  defineSmoke(),
20
+
21
+ defineTick(),
19
22
  ];
@@ -21,10 +21,11 @@ import { smokeTestRegistry } from '../../tests/smokeTestRegistry';
21
21
  // Runs one registered test, returning its completed result entry.
22
22
  function* askRunSmokeTest(
23
23
  test: SmokeTestDefinition,
24
- pending: SmokeTestResult
24
+ pending: SmokeTestResult,
25
+ runId: string
25
26
  ): AskResponse<SmokeTestResult> {
26
27
  const startedAt = yield* askDateNow();
27
- const outcome = yield* askCatch(test.askRun());
28
+ const outcome = yield* askCatch(test.askRun(runId));
28
29
  const finishedAt = yield* askDateNow();
29
30
 
30
31
  return {
@@ -53,7 +54,8 @@ export function* askExecuteSmokeRun(runId: string): AskResponse<SmokeRun> {
53
54
  for (let index = 0; index < smokeTestRegistry.length; index += 1) {
54
55
  const result = yield* askRunSmokeTest(
55
56
  smokeTestRegistry[index],
56
- smokeRun.tests[index]
57
+ smokeRun.tests[index],
58
+ runId
57
59
  );
58
60
 
59
61
  smokeRun = {
@@ -2,7 +2,13 @@ import { AskResponse } from 'quidproquo';
2
2
 
3
3
  // A registered smoke test. `askRun` passes by returning and fails by throwing
4
4
  // (askThrowError); the runner catches and records the error text as the message.
5
+ //
6
+ // The run id is handed to every test, and most ignore it: a test that leaves a
7
+ // mark somewhere durable stamps it, so what it later reads back is provably
8
+ // its own rather than the previous run's. Declaring the parameter costs the
9
+ // tests that do not want it nothing, since a zero-argument function still
10
+ // satisfies this.
5
11
  export type SmokeTestDefinition = {
6
12
  name: string;
7
- askRun: () => AskResponse<void>;
13
+ askRun: (runId: string) => AskResponse<void>;
8
14
  };
@@ -0,0 +1,88 @@
1
+ import {
2
+ askDateNow,
3
+ askDelay,
4
+ askKeyValueStoreGet,
5
+ askKeyValueStoreUpsert,
6
+ AskResponse,
7
+ Nullable,
8
+ } from 'quidproquo';
9
+
10
+ import { SCHEDULE_TICK_NAME } from '../../../tick/constants/scheduleTickName';
11
+ import { SCHEDULE_TICK_STORE } from '../../../tick/constants/scheduleTickStore';
12
+ import { ScheduleTickRecord } from '../../../tick/models/ScheduleTickRecord';
13
+ import { askSmokeAssert } from '../askSmokeAssert';
14
+
15
+ // The schedule runs every minute, so the wait has to cover a full boundary
16
+ // plus the stream hop after it. Generous rather than tight: the first fire
17
+ // after a deploy is the slowest one, and a flake here blocks a deploy.
18
+ const POLL_ATTEMPTS = 40;
19
+ const POLL_INTERVAL_MS = 3000;
20
+
21
+ // Which link stalled, from the state the chain left behind. Worth the few
22
+ // lines: "the schedule never fired" and "the stream never fired" send you to
23
+ // completely different places, and a bare timeout says neither.
24
+ const describeStall = (
25
+ tick: Nullable<ScheduleTickRecord>,
26
+ runId: string
27
+ ): string => {
28
+ if (!tick) {
29
+ return 'the seeded tick row is gone, so something else deleted it';
30
+ }
31
+
32
+ if (tick.runId !== runId) {
33
+ return `the tick row now belongs to run [${tick.runId}], so a concurrent smoke run overwrote it`;
34
+ }
35
+
36
+ if (!tick.processedAt) {
37
+ return 'the schedule never fired: the row was never marked processed';
38
+ }
39
+
40
+ return `the schedule fired at ${tick.processedAt} but the store's stream never acknowledged it`;
41
+ };
42
+
43
+ /**
44
+ * Proves a recurring schedule fires, and that a key-value store's change
45
+ * stream delivers, in one wait.
46
+ *
47
+ * Seeds one row stamped with this run, then waits for that same row to come
48
+ * back acknowledged - which only happens if the schedule picked it up and
49
+ * stamped processedAt, and the store's stream then saw that change and stamped
50
+ * acknowledgedAt. Deployed that is an EventBridge rule and a DynamoDB stream;
51
+ * locally it is the dev server's ticker and its stream implementation. Same
52
+ * assertion either way.
53
+ *
54
+ * Matching on the run id is what makes this unambiguous without deleting
55
+ * anything first: a stale row is one that does not match, so there is never a
56
+ * window where the record is missing, and never a question of whether what was
57
+ * read is recent enough to count.
58
+ *
59
+ * Slow by nature - up to a minute of real time, because that is how long a
60
+ * per-minute schedule can take to come round.
61
+ */
62
+ export function* askRunScheduleTest(runId: string): AskResponse<void> {
63
+ yield* askKeyValueStoreUpsert<ScheduleTickRecord>(SCHEDULE_TICK_STORE, {
64
+ scheduleName: SCHEDULE_TICK_NAME,
65
+ runId,
66
+ requestedAt: yield* askDateNow(),
67
+ });
68
+
69
+ let tick: Nullable<ScheduleTickRecord> = null;
70
+
71
+ for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
72
+ yield* askDelay(POLL_INTERVAL_MS);
73
+
74
+ tick = yield* askKeyValueStoreGet<ScheduleTickRecord>(
75
+ SCHEDULE_TICK_STORE,
76
+ SCHEDULE_TICK_NAME
77
+ );
78
+
79
+ if (tick?.runId === runId && tick.acknowledgedAt) {
80
+ return;
81
+ }
82
+ }
83
+
84
+ yield* askSmokeAssert(
85
+ false,
86
+ `no acknowledgement within ${(POLL_ATTEMPTS * POLL_INTERVAL_MS) / 1000}s: ${describeStall(tick, runId)}`
87
+ );
88
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunScheduleTest';
@@ -4,6 +4,7 @@ import { askRunEventBusTest } from './eventBus/askRunEventBusTest';
4
4
  import { askRunKeyValueStoreTest } from './keyValueStore/askRunKeyValueStoreTest';
5
5
  import { askRunNoopTest } from './noop/askRunNoopTest';
6
6
  import { askRunParameterTest } from './parameter/askRunParameterTest';
7
+ import { askRunScheduleTest } from './schedule/askRunScheduleTest';
7
8
  import { askRunSecretTest } from './secret/askRunSecretTest';
8
9
  import { askRunStorageDriveTest } from './storageDrive/askRunStorageDriveTest';
9
10
  import { SmokeTestDefinition } from './SmokeTestDefinition';
@@ -26,4 +27,5 @@ export const smokeTestRegistry: SmokeTestDefinition[] = [
26
27
  name: 'crossServiceStorageDrive',
27
28
  askRun: askRunCrossServiceStorageDriveTest,
28
29
  },
30
+ { name: 'schedule', askRun: askRunScheduleTest },
29
31
  ];
@@ -0,0 +1,50 @@
1
+ import {
2
+ defineKeyValueStore,
3
+ defineRecurringSchedule,
4
+ QPQConfig,
5
+ } from 'quidproquo';
6
+
7
+ import { SCHEDULE_TICK_STORE } from '../constants/scheduleTickStore';
8
+ import { ScheduleTickRecord } from '../models/ScheduleTickRecord';
9
+
10
+ /**
11
+ * A heartbeat schedule, and the one row that lets a test prove it fired.
12
+ *
13
+ * The chain the smoke suite's schedule test drives, all on a single record:
14
+ *
15
+ * test seeds it -> the schedule stamps processedAt
16
+ * -> the store's stream stamps acknowledgedAt
17
+ * -> the test sees acknowledgedAt for its own run
18
+ *
19
+ * One wait covers three things that are otherwise untested together: that a
20
+ * declared schedule fires at all, that it fires the same way locally as
21
+ * deployed, and that a key-value store's change stream delivers.
22
+ *
23
+ * Every minute, which is the fastest a schedule can go and the only cadence a
24
+ * test can reasonably wait out.
25
+ */
26
+ export const defineTick = (): QPQConfig => [
27
+ defineKeyValueStore<ScheduleTickRecord>(
28
+ SCHEDULE_TICK_STORE,
29
+ 'scheduleName',
30
+ [],
31
+ {
32
+ onStream: {
33
+ runtime: {
34
+ basePath: __dirname,
35
+ relativePath: '../entry/kvsStream/onScheduleTickStream',
36
+ functionName: 'onScheduleTickStream',
37
+ },
38
+ },
39
+ }
40
+ ),
41
+
42
+ defineRecurringSchedule(
43
+ { everyMinutes: 1 },
44
+ {
45
+ basePath: __dirname,
46
+ relativePath: '../entry/schedule/onTick',
47
+ functionName: 'onTick',
48
+ }
49
+ ),
50
+ ];
@@ -0,0 +1 @@
1
+ export * from './defineTick';
@@ -0,0 +1,2 @@
1
+ export * from './scheduleTickName';
2
+ export * from './scheduleTickStore';
@@ -0,0 +1,3 @@
1
+ // Key of the single heartbeat row. One row, overwritten every tick, so the
2
+ // store answers exactly one question: when did this schedule last fire.
3
+ export const SCHEDULE_TICK_NAME = 'onTick';
@@ -0,0 +1,3 @@
1
+ // The store the tick schedule writes its heartbeat into. Local to this
2
+ // service: unlike the smoke probe resources, nothing cross-service reaches it.
3
+ export const SCHEDULE_TICK_STORE = 'scheduleTick';
@@ -0,0 +1 @@
1
+ export * from './onScheduleTickStream';
@@ -0,0 +1,41 @@
1
+ import {
2
+ askDateNow,
3
+ askKeyValueStoreUpsert,
4
+ AskResponse,
5
+ KvsStreamEventResponse,
6
+ KvsStreamRecord,
7
+ } from 'quidproquo';
8
+
9
+ import { SCHEDULE_TICK_STORE } from '../../constants/scheduleTickStore';
10
+ import { ScheduleTickRecord } from '../../models/ScheduleTickRecord';
11
+
12
+ /**
13
+ * Stamps acknowledgedAt on a tick row the schedule has marked processed.
14
+ *
15
+ * The last link in the smoke chain: the test seeds a row, the schedule marks
16
+ * it, and this proves the store's change stream delivered that change. Nothing
17
+ * else in the suite exercises a stream, so a stream that stopped firing would
18
+ * otherwise go unnoticed.
19
+ *
20
+ * It writes back into the store it streams from, which is the shape that
21
+ * usually means an infinite loop. It terminates here because the guard is
22
+ * self-limiting: the only write it makes is the one that sets acknowledgedAt,
23
+ * and a row with acknowledgedAt already set falls out at the first line. So
24
+ * one processed row costs exactly one extra no-op delivery, and testing that
25
+ * no-op is part of the point - a handler writing to its own table is ordinary,
26
+ * and getting the guard wrong is the interesting failure.
27
+ */
28
+ export function* onScheduleTickStream(
29
+ record: KvsStreamRecord<ScheduleTickRecord>
30
+ ): AskResponse<KvsStreamEventResponse> {
31
+ // Also covers the test's own seed write (no processedAt yet) and a Remove
32
+ // (no new image at all), both of which arrive here and are not ours to act on.
33
+ if (!record.newImage?.processedAt || record.newImage.acknowledgedAt) {
34
+ return;
35
+ }
36
+
37
+ yield* askKeyValueStoreUpsert<ScheduleTickRecord>(SCHEDULE_TICK_STORE, {
38
+ ...record.newImage,
39
+ acknowledgedAt: yield* askDateNow(),
40
+ });
41
+ }
@@ -0,0 +1,45 @@
1
+ import {
2
+ askKeyValueStoreGet,
3
+ askKeyValueStoreUpsert,
4
+ askLogCreate,
5
+ AskResponse,
6
+ LogLevelEnum,
7
+ ScheduledEventParams,
8
+ } from 'quidproquo';
9
+
10
+ import { SCHEDULE_TICK_NAME } from '../../constants/scheduleTickName';
11
+ import { SCHEDULE_TICK_STORE } from '../../constants/scheduleTickStore';
12
+ import { ScheduleTickRecord } from '../../models/ScheduleTickRecord';
13
+
14
+ /**
15
+ * The heartbeat. Deployed this is an EventBridge rule; locally the dev
16
+ * server's ticker fires it on the same minute.
17
+ *
18
+ * Marks a pending request as processed, and does nothing when there is no
19
+ * request outstanding - so between smoke runs this is a log line and a read,
20
+ * not a write every minute forever.
21
+ *
22
+ * Records the event's own `time` rather than reading a clock, because that is
23
+ * what the scheduler believes the firing minute to be, and agreement on that
24
+ * between the two runtimes is the thing worth proving.
25
+ */
26
+ export function* onTick(event: ScheduledEventParams): AskResponse<void> {
27
+ yield* askLogCreate(
28
+ LogLevelEnum.Info,
29
+ `tick ${event.time} (correlation ${event.correlation})`
30
+ );
31
+
32
+ const pending = yield* askKeyValueStoreGet<ScheduleTickRecord>(
33
+ SCHEDULE_TICK_STORE,
34
+ SCHEDULE_TICK_NAME
35
+ );
36
+
37
+ if (!pending || pending.processedAt) {
38
+ return;
39
+ }
40
+
41
+ yield* askKeyValueStoreUpsert<ScheduleTickRecord>(SCHEDULE_TICK_STORE, {
42
+ ...pending,
43
+ processedAt: event.time,
44
+ });
45
+ }
@@ -0,0 +1 @@
1
+ export * from './config';
@@ -0,0 +1,17 @@
1
+ // The single row the whole schedule chain writes to, one field per actor:
2
+ //
3
+ // requestedAt the smoke test, seeding the run
4
+ // processedAt the schedule, when its minute comes round
5
+ // acknowledgedAt the store's own change stream, having seen processedAt
6
+ //
7
+ // `runId` is the smoke run that seeded it, so what the test reads back is
8
+ // provably its own rather than the previous run's. Nothing is ever deleted: a
9
+ // stale row is one whose runId does not match, so there is no window where the
10
+ // record is missing.
11
+ export type ScheduleTickRecord = {
12
+ scheduleName: string;
13
+ runId: string;
14
+ requestedAt: string;
15
+ processedAt?: string;
16
+ acknowledgedAt?: string;
17
+ };
@@ -0,0 +1 @@
1
+ export * from './ScheduleTickRecord';
@@ -1,20 +1,21 @@
1
1
  ---
2
2
  title: defineRecurringSchedule
3
- description: Define a recurring schedule a cron-driven trigger that runs a story on a timetable.
3
+ description: Define a recurring schedule, a trigger that runs a story on a timetable.
4
4
  ---
5
5
 
6
6
  # defineRecurringSchedule
7
7
 
8
- Defines a **recurring schedule**: a time-based trigger that runs a story on a cron timetable, with no incoming request. Use it for periodic work — nightly cleanups, polling, report generation, cache warming. Like a [queue](./queue.md) or [event bus](./event-bus.md), a schedule is an **event source**: each fire delivers a `ScheduledEvent` to the target story through the same [askProcessEvent](../../actions/core/event/ask-process-event.md) pipeline.
8
+ Defines a **recurring schedule**: a time-based trigger that runs a story on a timetable, with no incoming request. Use it for periodic work — nightly cleanups, polling, report generation, cache warming. Like a [queue](./queue.md) or [event bus](./event-bus.md), a schedule is an **event source**: each fire delivers a `ScheduledEvent` to the target story through the same [askProcessEvent](../../actions/core/event/ask-process-event.md) pipeline.
9
9
 
10
- - **On AWS:** deploys an **EventBridge rule** with a cron schedule expression and a **consumer Lambda** as its target (`QpqCoreRecurringScheduleConstruct` in `quidproquo-deploy-awscdk`). The rule fires on the cron cadence and invokes the Lambda, passing the schedule's `metadata` as the event `detail`. The Lambda has a 15-minute timeout, and `maxConcurrentExecutions` (when set) becomes the Lambda's reserved concurrent executions.
10
+ - **Locally:** the dev server ticks once a minute and runs any schedule whose recurrence matches that UTC minute. See [Locally](#locally) below.
11
+ - **On AWS:** deploys an **EventBridge rule** whose cron expression is rendered from the recurrence and a **consumer Lambda** as its target (`QpqCoreRecurringScheduleConstruct` in `quidproquo-deploy-awscdk`). The rule fires on the cron cadence and invokes the Lambda, passing the schedule's `metadata` as the event `detail`. The Lambda has a 15-minute timeout, and `maxConcurrentExecutions` (when set) becomes the Lambda's reserved concurrent executions.
11
12
 
12
13
  ```typescript
13
14
  import { defineRecurringSchedule } from 'quidproquo-core';
14
15
 
15
16
  export default [
16
- // Every day at 3 AM (server time)
17
- defineRecurringSchedule('0 0 3 * * ? *', '/entry/schedule/onNightlyCleanup::onNightlyCleanup'),
17
+ // Every day at 3am UTC
18
+ defineRecurringSchedule({ dailyAtUtc: { hour: 3, minute: 0 } }, '/entry/schedule/onNightlyCleanup::onNightlyCleanup'),
18
19
  ];
19
20
  ```
20
21
 
@@ -22,7 +23,7 @@ export default [
22
23
 
23
24
  ```typescript
24
25
  function defineRecurringSchedule(
25
- cronExpression: string,
26
+ recurrence: ScheduleRecurrence,
26
27
  runtime: QpqFunctionRuntime,
27
28
  options?: QPQConfigAdvancedScheduleSettings,
28
29
  ): ScheduleQPQConfigSetting;
@@ -30,29 +31,21 @@ function defineRecurringSchedule(
30
31
 
31
32
  ## Parameters
32
33
 
33
- ### `cronExpression` — `string` (required)
34
+ ### `recurrence` — `ScheduleRecurrence` (required)
34
35
 
35
- The cron expression that controls when the schedule fires. On AWS this is wrapped as `cron(<cronExpression>)` in the EventBridge rule, so it uses the six-field AWS EventBridge cron syntax:
36
+ When the schedule fires, declared as intent rather than as a cron string. It is deliberately platform-neutral: an AWS EventBridge cron expression is one *rendering* of it, produced at deploy time, and the dev server matches the same declaration against the clock.
36
37
 
37
- ```
38
- minutes hours day-of-month month day-of-week year
39
- ```
38
+ | Recurrence | Fires |
39
+ | --- | --- |
40
+ | `{ everyMinutes: n }` | every `n` minutes, on the hour. `n` must divide 60 |
41
+ | `{ everyHours: n, atMinute?: m }` | every `n` hours at minute `m` (default 0). `n` must divide 24 |
42
+ | `{ dailyAtUtc: { hour, minute } }` | once a day |
43
+ | `{ weeklyAtUtc: { day, hour, minute } }` | once a week, `day` being a `DayOfWeek` |
44
+ | `{ monthlyAtUtc: { dayOfMonth, hour, minute } }` | once a month. A date past the end of a short month simply does not occur that month |
40
45
 
41
- | Field | Values | Wildcards |
42
- | --- | --- | --- |
43
- | Minutes | `0-59` | `,` `-` `*` `/` |
44
- | Hours | `0-23` | `,` `-` `*` `/` |
45
- | Day-of-month | `1-31` | `,` `-` `*` `?` `/` `L` `W` |
46
- | Month | `1-12` or `JAN-DEC` | `,` `-` `*` `/` |
47
- | Day-of-week | `1-7` or `SUN-SAT` | `,` `-` `*` `?` `L` `#` |
48
- | Year | `1970-2199` | `,` `-` `*` `/` |
46
+ **Every time is UTC.** The deployed scheduler evaluates in UTC, so there is no local-timezone option: 3am in Brisbane is `{ dailyAtUtc: { hour: 17, minute: 0 } }`, and saying so in the config beats a comment that goes stale twice a year.
49
47
 
50
- You cannot use `*` in both the day-of-month and day-of-week fields at once put `?` in the one you don't want to constrain. Examples:
51
-
52
- - `'* * * * ? *'` — every minute
53
- - `'0/10 * * * ? *'` — every 10 minutes
54
- - `'0 0 3 * * ? *'` — every day at 3 AM
55
- - `'0 0 3 ? * 1 *'` — every Monday at 3 AM
48
+ An interval that cannot be scheduled evenly (`{ everyMinutes: 7 }`) throws an `InvalidScheduleRecurrenceError` when the config is evaluated, so it fails at synth and at dev-server boot rather than at some unlucky hour in production. The reason it is refused rather than approximated: AWS renders an interval as `0/n`, which restarts at the top of every hour, so seven-minute steps would fire at :00 :07 ... :56 and then leave a four-minute gap.
56
49
 
57
50
  ### `runtime` — `QpqFunctionRuntime` (required)
58
51
 
@@ -99,19 +92,29 @@ import { defineRecurringSchedule } from 'quidproquo-core';
99
92
 
100
93
  export default [
101
94
  // Poll an upstream every 10 minutes
102
- defineRecurringSchedule('0/10 * * * ? *', '/entry/schedule/onPoll::onPoll'),
95
+ defineRecurringSchedule({ everyMinutes: 10 }, '/entry/schedule/onPoll::onPoll'),
103
96
 
104
97
  // Nightly report, capped to a single concurrent run, with metadata
105
- defineRecurringSchedule('0 0 2 * * ? *', '/entry/schedule/onNightlyReport::onNightlyReport', {
98
+ defineRecurringSchedule({ dailyAtUtc: { hour: 2, minute: 0 } }, '/entry/schedule/onNightlyReport::onNightlyReport', {
106
99
  maxConcurrentExecutions: 1,
107
100
  metadata: { report: 'daily-summary' },
108
101
  }),
109
102
  ];
110
103
  ```
111
104
 
105
+ ## Locally
106
+
107
+ The dev server arms every schedule its services own and ticks once a minute, running any whose recurrence matches that UTC minute. It lists what it armed at boot:
108
+
109
+ ```
110
+ [schedule] 2 schedule(s) armed (utc):
111
+ [schedule] flow/onPoll {"everyMinutes":10}
112
+ [schedule] flow/onNightlyReport {"dailyAtUtc":{"hour":2,"minute":0}}
113
+ ```
114
+
112
115
  ## Related
113
116
 
114
117
  - [askProcessEvent](../../actions/core/event/ask-process-event.md) — the pipeline that runs the target story for each schedule fire.
115
118
  - [defineQueue](./queue.md) and [defineEventBus](./event-bus.md) — the other core event sources.
116
119
  - [defineDeployEvent](./deploy-event.md) — a related time/lifecycle-based trigger that runs at deploy time rather than on a timetable.
117
- - **AWS implementation:** `QpqCoreRecurringScheduleConstruct` (EventBridge rule + target Lambda) in `quidproquo-deploy-awscdk`.
120
+ - **AWS implementation:** `QpqCoreRecurringScheduleConstruct` (EventBridge rule + target Lambda) in `quidproquo-deploy-awscdk`. `renderAwsCronExpression`, alongside it, is the only place in the codebase that knows the EventBridge cron dialect.
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // Drives one smoke run against a deployed test service and exits non-zero if
3
- // it does not pass: mint a GitHub Actions OIDC token, POST /smoke/run, poll
4
- // GET /smoke/run/{runId} until the run finishes or the deadline passes.
2
+ // Runs the smoke suite against a just-deployed test service and exits non-zero
3
+ // if it does not pass. The run itself lives in scripts/smoke/runSmokeRun.mjs,
4
+ // shared with localSmoke.mjs; this file only resolves which api to point it at.
5
5
  //
6
- // Runs inside the deployed-smoke workflow job, which supplies:
6
+ // Runs inside the deploy workflow's deploy job, which supplies:
7
7
  // SMOKE_ENVIRONMENT the environment that was just deployed (development, staging)
8
8
  // ACTIONS_ID_TOKEN_REQUEST_URL provided by Actions when the job has id-token: write
9
9
  // ACTIONS_ID_TOKEN_REQUEST_TOKEN provided by Actions when the job has id-token: write
@@ -13,16 +13,13 @@
13
13
  // the api gateway maps each service under its own base path (see the
14
14
  // CfnBasePathMapping in quidproquo-deploy-awscdk's api construct):
15
15
  // https://api.<environment>.<domain>/<service>
16
- //
17
- // No dependencies: Node 24's global fetch is all it needs.
18
16
 
19
17
  import { readFileSync } from 'node:fs';
20
18
  import { dirname, join } from 'node:path';
21
19
  import { fileURLToPath } from 'node:url';
22
20
 
23
- const AUDIENCE = 'qpq-smoke';
24
- const DEADLINE_MS = 5 * 60 * 1000;
25
- const POLL_MS = 5 * 1000;
21
+ import { runSmokeRun } from './smoke/runSmokeRun.mjs';
22
+
26
23
  const SERVICE_NAME = 'test';
27
24
  const DEPLOY_CONFIG_PATH = join(
28
25
  dirname(fileURLToPath(import.meta.url)),
@@ -56,79 +53,9 @@ const deriveApiUrl = (environment) => {
56
53
 
57
54
  const environment = requireEnv('SMOKE_ENVIRONMENT');
58
55
  const apiUrl = deriveApiUrl(environment);
59
- const tokenRequestUrl = requireEnv('ACTIONS_ID_TOKEN_REQUEST_URL');
60
- const tokenRequestToken = requireEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN');
61
56
 
62
57
  log(`environment=${environment} api=${apiUrl}`);
63
58
 
64
- // OIDC tokens are short-lived (minutes), about as long as the poll deadline,
65
- // so every request mints a fresh one rather than racing the expiry.
66
- const mintToken = async () => {
67
- const response = await fetch(`${tokenRequestUrl}&audience=${AUDIENCE}`, {
68
- headers: { Authorization: `bearer ${tokenRequestToken}` },
69
- });
70
- if (!response.ok) {
71
- throw new Error(`token request failed with ${response.status}`);
72
- }
73
- const body = await response.json();
74
- if (!body.value) {
75
- throw new Error('token request returned no value');
76
- }
77
- return body.value;
78
- };
79
-
80
- const callApi = async (method, path) => {
81
- const response = await fetch(`${apiUrl}${path}`, {
82
- method,
83
- headers: { Authorization: `Bearer ${await mintToken()}` },
84
- });
85
- if (!response.ok) {
86
- throw new Error(
87
- `${method} ${path} failed with ${response.status}: ${await response.text()}`
88
- );
89
- }
90
- return response.json();
91
- };
92
-
93
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
94
-
95
- const printTests = (tests) => {
96
- for (const test of tests) {
97
- log(
98
- ` #${test.id} ${test.name.padEnd(24)} ${test.status.padEnd(8)} ${test.message}`
99
- );
100
- }
101
- };
102
-
103
- const main = async () => {
104
- const { runId } = await callApi('POST', '/smoke/run');
105
- if (!runId) {
106
- fail('POST /smoke/run returned no runId');
107
- }
108
- log(`started run ${runId}`);
109
-
110
- const deadline = Date.now() + DEADLINE_MS;
111
- while (Date.now() < deadline) {
112
- const run = await callApi('GET', `/smoke/run/${runId}`);
113
- const { summary } = run;
114
- log(
115
- `status=${run.status} completed ${summary.completed}/${summary.total} passed ${summary.passed} failed ${summary.failed}`
116
- );
117
-
118
- if (run.status === 'passed' || run.status === 'failed') {
119
- printTests(run.tests);
120
- if (run.status === 'failed') {
121
- fail(`run ${runId} failed`);
122
- }
123
- return;
124
- }
125
-
126
- await sleep(POLL_MS);
127
- }
128
-
129
- fail(`timed out waiting for run ${runId}`);
130
- };
131
-
132
- main().catch((error) =>
59
+ runSmokeRun(apiUrl, log).catch((error) =>
133
60
  fail(error instanceof Error ? error.message : String(error))
134
61
  );
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // Runs the smoke suite against a dev server this script starts, and exits
3
+ // non-zero if it does not pass. The run itself lives in
4
+ // scripts/smoke/runSmokeRun.mjs, shared with deployedSmoke.mjs.
5
+ //
6
+ // This is a pre-flight gate, not a replacement for the deployed smoke: the
7
+ // same stories run against the dev server's implementations instead of AWS, so
8
+ // it catches a broken probe, or the dev server drifting from deployed
9
+ // behaviour, on a pull request rather than after a deploy. It cannot catch
10
+ // anything the deployed run exists for - iam grants, resource naming, cdk
11
+ // wiring, api gateway base path mapping.
12
+ //
13
+ // Needs a GitHub Actions job with `permissions: id-token: write` and
14
+ // `environment: development`, because the smoke routes validate the token's
15
+ // environment claim against the service's own module environment - and a dev
16
+ // server is always development (primeDeployEnvFromConfig defaults it), whatever
17
+ // environment the deploy that follows is targeting.
18
+ //
19
+ // The api url is derived, not configured, same as the deployed script - but to
20
+ // a different SHAPE, which is why the two derive separately rather than
21
+ // sharing a template. Deployed, api gateway maps each service under its own
22
+ // base path on the api domain. Locally the dev server routes on
23
+ // /{apiSubdomain}/{serviceName} (see apiImplementation's devPath), and the app
24
+ // declares defineApi('api', ...):
25
+ // http://localhost:8080/api/test
26
+
27
+ import { spawn } from 'node:child_process';
28
+ import { dirname, join } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ import { runSmokeRun } from './smoke/runSmokeRun.mjs';
32
+
33
+ // The dev server defaults to this anyway; set explicitly so a CI runner that
34
+ // already has ENVIRONMENT set (the deploy workflow does) cannot change what
35
+ // the smoke routes expect from the token. Must match the job's `environment:`.
36
+ const ENVIRONMENT = 'development';
37
+
38
+ // There is more than one app under apps/, so the app has to be named: without
39
+ // it the cli exits with the app list rather than picking one.
40
+ const APP_NAME = 'qpqjs';
41
+
42
+ // 8080 is the dev server's api port, set in the generated entry.
43
+ const BASE_URL = 'http://localhost:8080';
44
+ const API_URL = `${BASE_URL}/api/test`;
45
+ const READY_URL = `${BASE_URL}/admin/service/ready`;
46
+
47
+ // Generous: a cold start bundles every service with rspack before the server
48
+ // process even launches.
49
+ const READY_TIMEOUT_MS = 5 * 60 * 1000;
50
+ const READY_POLL_MS = 2 * 1000;
51
+
52
+ // Above the dev server's own 5s worst-case shutdown budget.
53
+ const STOP_TIMEOUT_MS = 15 * 1000;
54
+
55
+ const APP_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
56
+
57
+ // The .bin shim npm links on install, which points at the cli's built entry.
58
+ const QPQ_BIN = join(APP_ROOT, 'node_modules', '.bin', 'qpq');
59
+
60
+ const log = (message) => console.log(`local-smoke: ${message}`);
61
+
62
+ const fail = (message) => {
63
+ console.error(`local-smoke: ${message}`);
64
+ process.exit(1);
65
+ };
66
+
67
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
68
+
69
+ /**
70
+ * Wait until the dev server reports every plugin started.
71
+ *
72
+ * Deliberately not a TCP connect: the api plugin binds its port partway
73
+ * through the start sequence, so a socket opens before the queue and the
74
+ * stores are up, and the first POST would race them.
75
+ */
76
+ const waitForReady = async (server) => {
77
+ const deadline = Date.now() + READY_TIMEOUT_MS;
78
+
79
+ while (Date.now() < deadline) {
80
+ if (server.exitCode !== null) {
81
+ throw new Error(
82
+ `dev server exited with ${server.exitCode} before it was ready`
83
+ );
84
+ }
85
+
86
+ try {
87
+ const response = await fetch(READY_URL);
88
+ if (response.ok) {
89
+ return;
90
+ }
91
+ } catch {
92
+ // Not listening yet. Nothing to report until the deadline passes.
93
+ }
94
+
95
+ await sleep(READY_POLL_MS);
96
+ }
97
+
98
+ throw new Error(`dev server was not ready within ${READY_TIMEOUT_MS}ms`);
99
+ };
100
+
101
+ /**
102
+ * Stop the dev server and resolve with how it went.
103
+ *
104
+ * SIGTERM rather than SIGKILL because the graceful path is part of what this
105
+ * script checks: the server should stop accepting, drain the smoke run's
106
+ * in-flight work and checkpoint its stores, then exit 0.
107
+ *
108
+ * Code and signal are both reported because they mean different things and
109
+ * only one of them is ever a number. A code of 0 is the pass; a non-zero code
110
+ * means a teardown broke or a phase ran out of budget; a null code with a
111
+ * signal means nothing handled the signal and the process was killed where it
112
+ * stood, which is a shutdown that never ran rather than one that failed.
113
+ */
114
+ const stopServer = (server) =>
115
+ new Promise((resolve) => {
116
+ if (server.exitCode !== null) {
117
+ resolve({ code: server.exitCode, signal: null });
118
+ return;
119
+ }
120
+
121
+ const timer = setTimeout(() => {
122
+ log(
123
+ `dev server did not stop within ${STOP_TIMEOUT_MS}ms, sending SIGKILL`
124
+ );
125
+ server.kill('SIGKILL');
126
+ }, STOP_TIMEOUT_MS);
127
+
128
+ server.once('exit', (code, signal) => {
129
+ clearTimeout(timer);
130
+ resolve({ code, signal });
131
+ });
132
+
133
+ server.kill('SIGTERM');
134
+ });
135
+
136
+ const main = async () => {
137
+ log(`starting dev server (app=${APP_NAME} env=${ENVIRONMENT})`);
138
+
139
+ // node on the cli directly, NOT `npx qpq`: npx is another process in front
140
+ // of the one we need to signal, and SIGTERM stops at it. The cli would never
141
+ // hear the stop, its dev server child would never drain, and the exit would
142
+ // come back as a signal kill. One less layer means the signal lands where
143
+ // the handler is.
144
+ const server = spawn(
145
+ process.execPath,
146
+ [QPQ_BIN, 'go:dev:api', '--app', APP_NAME],
147
+ {
148
+ cwd: APP_ROOT,
149
+ stdio: 'inherit',
150
+ env: { ...process.env, ENVIRONMENT },
151
+ }
152
+ );
153
+
154
+ // The server is stopped either way, but a smoke failure is reported ahead of
155
+ // a dirty shutdown: it is the more useful of the two, and letting the
156
+ // shutdown check throw first would bury it.
157
+ let smokeError = null;
158
+
159
+ try {
160
+ await waitForReady(server);
161
+ log(`dev server ready, api=${API_URL}`);
162
+
163
+ await runSmokeRun(API_URL, log);
164
+ } catch (error) {
165
+ smokeError = error;
166
+ }
167
+
168
+ const { code, signal } = await stopServer(server);
169
+ log(`dev server exited with code=${code} signal=${signal}`);
170
+
171
+ if (smokeError) {
172
+ throw smokeError;
173
+ }
174
+
175
+ if (signal) {
176
+ throw new Error(
177
+ `dev server was killed by ${signal} instead of shutting down, so its teardown never ran`
178
+ );
179
+ }
180
+
181
+ if (code !== 0) {
182
+ throw new Error(`dev server did not shut down cleanly (exit ${code})`);
183
+ }
184
+ };
185
+
186
+ main().catch((error) =>
187
+ fail(error instanceof Error ? error.message : String(error))
188
+ );
@@ -0,0 +1,100 @@
1
+ // Drives one smoke run against a test service and reports whether it passed:
2
+ // mint a GitHub Actions OIDC token, POST /smoke/run, poll GET
3
+ // /smoke/run/{runId} until the run finishes or the deadline passes.
4
+ //
5
+ // Target-agnostic on purpose. The two callers differ only in the api url they
6
+ // resolve, which each derives for itself:
7
+ // deployedSmoke.mjs the just-deployed environment, from deploy.config.json
8
+ // localSmoke.mjs a dev server it started, on localhost
9
+ //
10
+ // Both talk to the same routes over the same protocol with the same token, so
11
+ // a local run rehearses the deployed one rather than approximating it.
12
+ //
13
+ // No dependencies: Node's global fetch is all it needs.
14
+
15
+ const AUDIENCE = 'qpq-smoke';
16
+ const DEADLINE_MS = 5 * 60 * 1000;
17
+ const POLL_MS = 5 * 1000;
18
+
19
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
+
21
+ // OIDC tokens are short-lived (minutes), about as long as the poll deadline,
22
+ // so every request mints a fresh one rather than racing the expiry.
23
+ const mintToken = async () => {
24
+ const tokenRequestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
25
+ const tokenRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
26
+
27
+ if (!tokenRequestUrl || !tokenRequestToken) {
28
+ throw new Error(
29
+ 'ACTIONS_ID_TOKEN_REQUEST_URL / ACTIONS_ID_TOKEN_REQUEST_TOKEN are not set. This needs a GitHub Actions job with `permissions: id-token: write`'
30
+ );
31
+ }
32
+
33
+ const response = await fetch(`${tokenRequestUrl}&audience=${AUDIENCE}`, {
34
+ headers: { Authorization: `bearer ${tokenRequestToken}` },
35
+ });
36
+ if (!response.ok) {
37
+ throw new Error(`token request failed with ${response.status}`);
38
+ }
39
+ const body = await response.json();
40
+ if (!body.value) {
41
+ throw new Error('token request returned no value');
42
+ }
43
+ return body.value;
44
+ };
45
+
46
+ const printTests = (log, tests) => {
47
+ for (const test of tests) {
48
+ log(
49
+ ` #${test.id} ${test.name.padEnd(24)} ${test.status.padEnd(8)} ${test.message}`
50
+ );
51
+ }
52
+ };
53
+
54
+ /**
55
+ * Run the smoke suite against `apiUrl` and resolve if it passed.
56
+ *
57
+ * Throws on anything else (a failed run, an unreachable api, a run that never
58
+ * finishes) so a caller can let it reach its top-level catch and exit 1.
59
+ */
60
+ export const runSmokeRun = async (apiUrl, log) => {
61
+ const callApi = async (method, path) => {
62
+ const response = await fetch(`${apiUrl}${path}`, {
63
+ method,
64
+ headers: { Authorization: `Bearer ${await mintToken()}` },
65
+ });
66
+ if (!response.ok) {
67
+ throw new Error(
68
+ `${method} ${path} failed with ${response.status}: ${await response.text()}`
69
+ );
70
+ }
71
+ return response.json();
72
+ };
73
+
74
+ const { runId } = await callApi('POST', '/smoke/run');
75
+ if (!runId) {
76
+ throw new Error('POST /smoke/run returned no runId');
77
+ }
78
+ log(`started run ${runId}`);
79
+
80
+ const deadline = Date.now() + DEADLINE_MS;
81
+ while (Date.now() < deadline) {
82
+ const run = await callApi('GET', `/smoke/run/${runId}`);
83
+ const { summary } = run;
84
+ log(
85
+ `status=${run.status} completed ${summary.completed}/${summary.total} passed ${summary.passed} failed ${summary.failed}`
86
+ );
87
+
88
+ if (run.status === 'passed' || run.status === 'failed') {
89
+ printTests(log, run.tests);
90
+ if (run.status === 'failed') {
91
+ throw new Error(`run ${runId} failed`);
92
+ }
93
+ return;
94
+ }
95
+
96
+ await sleep(POLL_MS);
97
+ }
98
+
99
+ throw new Error(`timed out waiting for run ${runId}`);
100
+ };