@kensio/yulin 1.21.25 → 1.21.26

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 (39) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +1 -0
  3. package/dist/sdk/error/sim-sdk.error.d.ts +18 -0
  4. package/dist/sdk/error/sim-sdk.error.js +41 -0
  5. package/dist/sdk/index.d.ts +1 -1
  6. package/dist/sdk/index.js +1 -1
  7. package/dist/sdk/send-abort.d.ts +28 -0
  8. package/dist/sdk/send-abort.js +112 -0
  9. package/dist/sdk/send-patch.js +3 -1
  10. package/dist/service/aws/factory/sim-aws-account-region-service-builder.js +1 -0
  11. package/dist/service/aws/sim-aws-properties.d.ts +8 -0
  12. package/dist/service/aws/sim-aws.d.ts +5 -0
  13. package/dist/service/aws/sim-aws.js +7 -1
  14. package/dist/service/scheduler/cfn/sim-cfn-schedule-properties.d.ts +0 -4
  15. package/dist/service/scheduler/cfn/sim-cfn-schedule-properties.js +2 -19
  16. package/dist/service/scheduler/cfn/sim-cfn-schedule-time-window.d.ts +10 -0
  17. package/dist/service/scheduler/cfn/sim-cfn-schedule-time-window.js +37 -0
  18. package/dist/service/scheduler/command/schedule/sim-scheduler-described-schedule.d.ts +1 -2
  19. package/dist/service/scheduler/command/schedule/sim-scheduler-described-schedule.js +2 -3
  20. package/dist/service/scheduler/command/schedule/sim-scheduler-schedule-writer.js +3 -0
  21. package/dist/service/scheduler/command/schedule/sim-scheduler-unsimulated-input.js +0 -25
  22. package/dist/service/scheduler/command/sim-scheduler-commands.d.ts +2 -0
  23. package/dist/service/scheduler/command/sim-scheduler-commands.js +1 -0
  24. package/dist/service/scheduler/schedule/sim-scheduler-pending-occurrences.d.ts +23 -0
  25. package/dist/service/scheduler/schedule/sim-scheduler-pending-occurrences.js +36 -0
  26. package/dist/service/scheduler/schedule/sim-scheduler-schedule.d.ts +7 -0
  27. package/dist/service/scheduler/schedule/sim-scheduler-schedule.js +5 -0
  28. package/dist/service/scheduler/schedule/sim-scheduler-schedules.d.ts +27 -1
  29. package/dist/service/scheduler/schedule/sim-scheduler-schedules.js +52 -8
  30. package/dist/service/scheduler/schedule/sim-scheduler-time-window.d.ts +33 -0
  31. package/dist/service/scheduler/schedule/sim-scheduler-time-window.js +93 -0
  32. package/dist/service/scheduler/sim-scheduler.d.ts +6 -0
  33. package/dist/service/scheduler/sim-scheduler.js +3 -1
  34. package/dist/util/random/sim-random.d.ts +35 -0
  35. package/dist/util/random/sim-random.js +33 -0
  36. package/docs/sdk/README.md +23 -0
  37. package/docs/services/scheduler/README.md +68 -5
  38. package/docs/time/README.md +18 -0
  39. package/package.json +2 -2
@@ -3,6 +3,7 @@ import type { SimAwsAccountRegionScope } from "../../aws/sim-aws-account-region-
3
3
  import type { SimSchedulerTarget } from "../target/sim-scheduler-target.js";
4
4
  import type { SimSchedulerScheduleName } from "./sim-scheduler-schedule-name.js";
5
5
  import type { SimSchedulerScheduleState } from "./sim-scheduler-schedule-state.js";
6
+ import type { SimSchedulerTimeWindow } from "./sim-scheduler-time-window.js";
6
7
  /**
7
8
  * What Scheduler does with a schedule once it has finished invoking its target.
8
9
  *
@@ -23,6 +24,8 @@ interface SimSchedulerScheduleProperties {
23
24
  readonly description?: string | undefined;
24
25
  /** The timezone the request named, which a describe reports back. */
25
26
  readonly timeZone?: string | undefined;
27
+ /** When, relative to each due time, the target is invoked. */
28
+ readonly timeWindow: SimSchedulerTimeWindow;
26
29
  readonly createdAt: Date;
27
30
  }
28
31
  /**
@@ -60,6 +63,10 @@ export declare class SimSchedulerSchedule {
60
63
  * than reporting the one it fell back to.
61
64
  */
62
65
  readonly timeZone: string | undefined;
66
+ /**
67
+ * The flexible time window each occurrence is invoked inside.
68
+ */
69
+ readonly timeWindow: SimSchedulerTimeWindow;
63
70
  readonly creationDate: Date;
64
71
  /**
65
72
  * When this schedule falls due, which is read as simulated time advances.
@@ -35,6 +35,10 @@ export class SimSchedulerSchedule {
35
35
  * than reporting the one it fell back to.
36
36
  */
37
37
  timeZone;
38
+ /**
39
+ * The flexible time window each occurrence is invoked inside.
40
+ */
41
+ timeWindow;
38
42
  creationDate;
39
43
  /**
40
44
  * When this schedule falls due, which is read as simulated time advances.
@@ -53,6 +57,7 @@ export class SimSchedulerSchedule {
53
57
  this.actionAfterCompletion = properties.actionAfterCompletion;
54
58
  this.description = properties.description;
55
59
  this.timeZone = properties.timeZone;
60
+ this.timeWindow = properties.timeWindow;
56
61
  this.creationDate = properties.createdAt;
57
62
  this.modified = properties.createdAt;
58
63
  this.held = properties.state;
@@ -1,4 +1,5 @@
1
1
  import type { BackgroundScheduler } from "../../../util/background/background.js";
2
+ import type { SimRandom } from "../../../util/random/sim-random.js";
2
3
  import type { SimSchedulerTargetDelivery } from "../delivery/sim-scheduler-target-delivery.js";
3
4
  import type { SimSchedulerSchedule } from "./sim-scheduler-schedule.js";
4
5
  import type { SimSchedulerScheduleStore } from "./sim-scheduler-schedule-store.js";
@@ -6,6 +7,8 @@ interface SimSchedulerSchedulesProperties {
6
7
  readonly schedules: SimSchedulerScheduleStore;
7
8
  readonly delivery: SimSchedulerTargetDelivery;
8
9
  readonly background: BackgroundScheduler;
10
+ /** Where each occurrence's moment inside a flexible window is drawn. */
11
+ readonly random: SimRandom;
9
12
  }
10
13
  /**
11
14
  * What makes a schedule fire, which is simulated time reaching it.
@@ -22,11 +25,19 @@ interface SimSchedulerSchedulesProperties {
22
25
  * no timer to cancel, only a firing that finds itself out of date. That is also
23
26
  * what makes an update reschedule from the new expression rather than keeping
24
27
  * the old due times, since an update stores a new schedule and arms it.
28
+ *
29
+ * A schedule with a flexible time window is armed for each due instant all
30
+ * the same, and then invokes its target at a moment drawn inside the window.
31
+ * The next occurrence is armed from the due instant rather than from the
32
+ * invocation, so the window delays each invocation without moving the
33
+ * schedule.
25
34
  */
26
35
  export declare class SimSchedulerSchedules {
27
36
  private readonly schedules;
28
37
  private readonly delivery;
29
38
  private readonly background;
39
+ private readonly random;
40
+ private readonly pending;
30
41
  constructor(properties: SimSchedulerSchedulesProperties);
31
42
  /**
32
43
  * Start a schedule, from now.
@@ -40,9 +51,24 @@ export declare class SimSchedulerSchedules {
40
51
  */
41
52
  private armAfter;
42
53
  /**
43
- * Fire a schedule that has fallen due, and arm it for the next time.
54
+ * Handle a schedule that has fallen due: arm the next occurrence, and
55
+ * invoke the target at the moment its time window gives this one.
56
+ */
57
+ private occur;
58
+ /**
59
+ * Invoke a schedule's target for one occurrence.
60
+ *
61
+ * A schedule deleted or replaced while its occurrence waited inside the
62
+ * window is not invoked. The delivery carries the due instant rather than
63
+ * the moment it was made, since that is the occurrence it belongs to. A
64
+ * schedule completes with whichever occurrence is invoked last, which need
65
+ * not be its last due instant when windows overlap.
44
66
  */
45
67
  private fire;
68
+ /**
69
+ * Whether a schedule has been deleted or replaced since it was armed.
70
+ */
71
+ private isStale;
46
72
  /**
47
73
  * Deal with a schedule that has no next occurrence.
48
74
  *
@@ -1,3 +1,4 @@
1
+ import { SimSchedulerPendingOccurrences } from "./sim-scheduler-pending-occurrences.js";
1
2
  /**
2
3
  * What makes a schedule fire, which is simulated time reaching it.
3
4
  *
@@ -13,15 +14,24 @@
13
14
  * no timer to cancel, only a firing that finds itself out of date. That is also
14
15
  * what makes an update reschedule from the new expression rather than keeping
15
16
  * the old due times, since an update stores a new schedule and arms it.
17
+ *
18
+ * A schedule with a flexible time window is armed for each due instant all
19
+ * the same, and then invokes its target at a moment drawn inside the window.
20
+ * The next occurrence is armed from the due instant rather than from the
21
+ * invocation, so the window delays each invocation without moving the
22
+ * schedule.
16
23
  */
17
24
  export class SimSchedulerSchedules {
18
25
  schedules;
19
26
  delivery;
20
27
  background;
28
+ random;
29
+ pending = new SimSchedulerPendingOccurrences();
21
30
  constructor(properties) {
22
31
  this.schedules = properties.schedules;
23
32
  this.delivery = properties.delivery;
24
33
  this.background = properties.background;
34
+ this.random = properties.random;
25
35
  }
26
36
  /**
27
37
  * Start a schedule, from now.
@@ -41,15 +51,45 @@ export class SimSchedulerSchedules {
41
51
  return;
42
52
  }
43
53
  this.background.scheduleAt(due, () => {
44
- this.fire(schedule, due);
54
+ this.occur(schedule, due);
55
+ return Promise.resolve();
56
+ });
57
+ }
58
+ /**
59
+ * Handle a schedule that has fallen due: arm the next occurrence, and
60
+ * invoke the target at the moment its time window gives this one.
61
+ */
62
+ occur(schedule, due) {
63
+ if (this.isStale(schedule)) {
64
+ return;
65
+ }
66
+ const last = schedule.schedule.nextAfter(due) === undefined;
67
+ if (!last) {
68
+ this.armAfter(schedule, due);
69
+ }
70
+ const at = schedule.timeWindow.invocationAt(due, this.random);
71
+ this.pending.begin(schedule);
72
+ if (at.getTime() === due.getTime()) {
73
+ this.fire(schedule, due, last);
74
+ return;
75
+ }
76
+ this.background.scheduleAt(at, () => {
77
+ this.fire(schedule, due, last);
45
78
  return Promise.resolve();
46
79
  });
47
80
  }
48
81
  /**
49
- * Fire a schedule that has fallen due, and arm it for the next time.
82
+ * Invoke a schedule's target for one occurrence.
83
+ *
84
+ * A schedule deleted or replaced while its occurrence waited inside the
85
+ * window is not invoked. The delivery carries the due instant rather than
86
+ * the moment it was made, since that is the occurrence it belongs to. A
87
+ * schedule completes with whichever occurrence is invoked last, which need
88
+ * not be its last due instant when windows overlap.
50
89
  */
51
- fire(schedule, due) {
52
- if (this.schedules.find(schedule.groupName, schedule.name.value) !== schedule) {
90
+ fire(schedule, due, last) {
91
+ const completed = this.pending.end(schedule, last);
92
+ if (this.isStale(schedule)) {
53
93
  return;
54
94
  }
55
95
  const invoked = schedule.state.isEnabled;
@@ -58,11 +98,15 @@ export class SimSchedulerSchedules {
58
98
  await this.delivery.deliver({ schedule, at: due });
59
99
  });
60
100
  }
61
- if (schedule.schedule.nextAfter(due) !== undefined) {
62
- this.armAfter(schedule, due);
63
- return;
101
+ if (completed) {
102
+ this.completed(schedule, invoked);
64
103
  }
65
- this.completed(schedule, invoked);
104
+ }
105
+ /**
106
+ * Whether a schedule has been deleted or replaced since it was armed.
107
+ */
108
+ isStale(schedule) {
109
+ return (this.schedules.find(schedule.groupName, schedule.name.value) !== schedule);
66
110
  }
67
111
  /**
68
112
  * Deal with a schedule that has no next occurrence.
@@ -0,0 +1,33 @@
1
+ import type { SimRandom } from "../../../util/random/sim-random.js";
2
+ import type { SimSchedulerFlexibleTimeWindow } from "../command/schedule/schedule.command.js";
3
+ /**
4
+ * When, relative to its due time, a schedule invokes its target.
5
+ *
6
+ * With `OFF` that is the due time itself. With `FLEXIBLE` it is some moment
7
+ * after the due time and inside the window, which AWS chooses and does not
8
+ * reveal. Firing on the dot instead would let a test pass by relying on
9
+ * timing AWS does not promise, so each occurrence draws its own moment.
10
+ */
11
+ export declare class SimSchedulerTimeWindow {
12
+ /**
13
+ * The window length in minutes, or nothing when the mode is `OFF`.
14
+ */
15
+ readonly minutes: number | undefined;
16
+ private constructor();
17
+ /**
18
+ * Read the window a request carries, which AWS requires on every one.
19
+ */
20
+ static of(window: SimSchedulerFlexibleTimeWindow | undefined): SimSchedulerTimeWindow;
21
+ /**
22
+ * The window as GetSchedule reports it, which is what the request sent.
23
+ */
24
+ get declared(): SimSchedulerFlexibleTimeWindow;
25
+ /**
26
+ * The instant one occurrence invokes its target, drawn from `random`.
27
+ *
28
+ * It is never before the due time and always before the window closes, so
29
+ * a test that moves the clock past the end of the window sees the
30
+ * invocation whatever was drawn.
31
+ */
32
+ invocationAt(due: Date, random: SimRandom): Date;
33
+ }
@@ -0,0 +1,93 @@
1
+ import { SimSchedulerValidationException } from "../error/sim-scheduler.error.js";
2
+ const minimumWindowInMinutes = 1;
3
+ const maximumWindowInMinutes = 1440;
4
+ const millisecondsPerMinute = 60_000;
5
+ /**
6
+ * Refuse a value the API model's own constraints reject, in the words AWS
7
+ * uses for them.
8
+ */
9
+ function constraintFailure(value, member, constraint) {
10
+ return new SimSchedulerValidationException(`1 validation error detected: Value '${value}' at ` +
11
+ `'flexibleTimeWindow.${member}' failed to satisfy constraint: ` +
12
+ `Member must ${constraint}`);
13
+ }
14
+ /**
15
+ * Read the length of a flexible window, refusing one outside 1 to 1440.
16
+ */
17
+ function windowLength(minutes) {
18
+ if (minutes === undefined) {
19
+ throw new SimSchedulerValidationException("Invalid parameter: FlexibleTimeWindow Reason: a FLEXIBLE window " +
20
+ "needs MaximumWindowInMinutes, from 1 to 1440");
21
+ }
22
+ if (!Number.isSafeInteger(minutes)) {
23
+ throw new SimSchedulerValidationException(`Invalid parameter: FlexibleTimeWindow Reason: MaximumWindowInMinutes ` +
24
+ `is a whole number of minutes, and ${String(minutes)} is not`);
25
+ }
26
+ if (minutes < minimumWindowInMinutes) {
27
+ throw constraintFailure(String(minutes), "maximumWindowInMinutes", `have value greater than or equal to ${String(minimumWindowInMinutes)}`);
28
+ }
29
+ if (minutes > maximumWindowInMinutes) {
30
+ throw constraintFailure(String(minutes), "maximumWindowInMinutes", `have value less than or equal to ${String(maximumWindowInMinutes)}`);
31
+ }
32
+ return minutes;
33
+ }
34
+ /**
35
+ * When, relative to its due time, a schedule invokes its target.
36
+ *
37
+ * With `OFF` that is the due time itself. With `FLEXIBLE` it is some moment
38
+ * after the due time and inside the window, which AWS chooses and does not
39
+ * reveal. Firing on the dot instead would let a test pass by relying on
40
+ * timing AWS does not promise, so each occurrence draws its own moment.
41
+ */
42
+ export class SimSchedulerTimeWindow {
43
+ /**
44
+ * The window length in minutes, or nothing when the mode is `OFF`.
45
+ */
46
+ minutes;
47
+ constructor(minutes) {
48
+ this.minutes = minutes;
49
+ }
50
+ /**
51
+ * Read the window a request carries, which AWS requires on every one.
52
+ */
53
+ static of(window) {
54
+ const mode = window?.Mode;
55
+ if (mode === undefined) {
56
+ throw new SimSchedulerValidationException("FlexibleTimeWindow is required, and its Mode is OFF or FLEXIBLE");
57
+ }
58
+ if (mode === "FLEXIBLE") {
59
+ return new SimSchedulerTimeWindow(windowLength(window?.MaximumWindowInMinutes));
60
+ }
61
+ if (mode !== "OFF") {
62
+ throw constraintFailure(mode, "mode", "satisfy enum value set: [OFF, FLEXIBLE]");
63
+ }
64
+ if (window?.MaximumWindowInMinutes !== undefined) {
65
+ throw new SimSchedulerValidationException("Invalid parameter: FlexibleTimeWindow Reason: " +
66
+ "MaximumWindowInMinutes is only allowed when Mode is FLEXIBLE");
67
+ }
68
+ return new SimSchedulerTimeWindow(undefined);
69
+ }
70
+ /**
71
+ * The window as GetSchedule reports it, which is what the request sent.
72
+ */
73
+ get declared() {
74
+ if (this.minutes === undefined) {
75
+ return { Mode: "OFF" };
76
+ }
77
+ return { Mode: "FLEXIBLE", MaximumWindowInMinutes: this.minutes };
78
+ }
79
+ /**
80
+ * The instant one occurrence invokes its target, drawn from `random`.
81
+ *
82
+ * It is never before the due time and always before the window closes, so
83
+ * a test that moves the clock past the end of the window sees the
84
+ * invocation whatever was drawn.
85
+ */
86
+ invocationAt(due, random) {
87
+ if (this.minutes === undefined) {
88
+ return due;
89
+ }
90
+ const offset = Math.floor(random.next() * this.minutes * millisecondsPerMinute);
91
+ return new Date(due.getTime() + offset);
92
+ }
93
+ }
@@ -1,4 +1,5 @@
1
1
  import { type BackgroundScheduler } from "../../util/background/background.js";
2
+ import { type SimRandom } from "../../util/random/sim-random.js";
2
3
  import type { SimSdkCommandRouter } from "../../sdk/router/sim-sdk-command-router.type.js";
3
4
  import type { SimAwsAccountRegionScope } from "../aws/sim-aws-account-region-scope.js";
4
5
  import type { SimIamInterServiceAuthZ } from "../iam/authorize/sim-iam-inter-service-auth-z.js";
@@ -21,6 +22,11 @@ interface SimSchedulerProperties {
21
22
  * in another simulated service is only reachable through SimAws.
22
23
  */
23
24
  readonly deliveryTargets?: SimSchedulerDeliveryTargets;
25
+ /**
26
+ * Where a flexible time window draws the moment each occurrence is invoked,
27
+ * which is the host's `Math.random` unless a test supplies its own.
28
+ */
29
+ readonly random?: SimRandom;
24
30
  }
25
31
  /**
26
32
  * Simulated EventBridge Scheduler. Handles SDK commands. Emulates AWS
@@ -1,4 +1,5 @@
1
1
  import { BackgroundTasks, } from "../../util/background/background.js";
2
+ import { SimHostRandom } from "../../util/random/sim-random.js";
2
3
  import { simAwsAccountRegionScopeFactory } from "../aws/sim-aws-account-region-scope.factory.js";
3
4
  import { simIamInRegion } from "../iam/authorize/sim-iam-region-auth-z.js";
4
5
  import { SimSchedulerCommands } from "./command/sim-scheduler-commands.js";
@@ -30,7 +31,7 @@ export class SimScheduler extends SimSchedulerInspection {
30
31
  });
31
32
  constructor(properties = {}) {
32
33
  super();
33
- const { accountRegionScope = simAwsAccountRegionScopeFactory.make(), background = new BackgroundTasks(), } = properties;
34
+ const { accountRegionScope = simAwsAccountRegionScopeFactory.make(), background = new BackgroundTasks(), random = new SimHostRandom(), } = properties;
34
35
  const iam = simIamInRegion(properties.iam, accountRegionScope.regionName);
35
36
  this.background = background;
36
37
  this.groupStore = new SimSchedulerScheduleGroupStore({
@@ -44,6 +45,7 @@ export class SimScheduler extends SimSchedulerInspection {
44
45
  background,
45
46
  deliveryTargets: properties.deliveryTargets,
46
47
  accountRegionScope,
48
+ random,
47
49
  });
48
50
  }
49
51
  /**
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Where a simulation draws the values AWS leaves to chance.
3
+ *
4
+ * Some AWS behaviour is unpredictable by design, such as the moment inside a
5
+ * Scheduler flexible time window at which the target is invoked. A simulation
6
+ * draws those values from here, so a test can make them repeatable by passing
7
+ * a seeded source and leave them unpredictable otherwise.
8
+ */
9
+ export interface SimRandom {
10
+ /**
11
+ * The next value, at least 0 and less than 1.
12
+ */
13
+ next(): number;
14
+ }
15
+ /**
16
+ * Draws from the host's `Math.random`, so values differ from run to run.
17
+ *
18
+ * This is the default, because a test that passes only for one particular
19
+ * draw is relying on something AWS does not promise.
20
+ */
21
+ export declare class SimHostRandom implements SimRandom {
22
+ next(): number;
23
+ }
24
+ /**
25
+ * Draws a repeatable sequence from a seed.
26
+ *
27
+ * Two sources built from the same seed give the same values in the same order,
28
+ * so a failure seen once can be reproduced. The generator is mulberry32, which
29
+ * is small and fast and has nothing to do with security.
30
+ */
31
+ export declare class SimSeededRandom implements SimRandom {
32
+ private state;
33
+ constructor(seed: number);
34
+ next(): number;
35
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Draws from the host's `Math.random`, so values differ from run to run.
3
+ *
4
+ * This is the default, because a test that passes only for one particular
5
+ * draw is relying on something AWS does not promise.
6
+ */
7
+ export class SimHostRandom {
8
+ next() {
9
+ return Math.random();
10
+ }
11
+ }
12
+ /**
13
+ * Draws a repeatable sequence from a seed.
14
+ *
15
+ * Two sources built from the same seed give the same values in the same order,
16
+ * so a failure seen once can be reproduced. The generator is mulberry32, which
17
+ * is small and fast and has nothing to do with security.
18
+ */
19
+ export class SimSeededRandom {
20
+ state;
21
+ constructor(seed) {
22
+ // Only the low 32 bits of the seed take part, as the generator works on
23
+ // 32-bit integers.
24
+ this.state = Math.trunc(seed) >>> 0;
25
+ }
26
+ next() {
27
+ this.state = (this.state + 0x6d_2b_79_f5) >>> 0;
28
+ let mixed = this.state;
29
+ mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1);
30
+ mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61);
31
+ return ((mixed ^ (mixed >>> 14)) >>> 0) / 4_294_967_296;
32
+ }
33
+ }
@@ -172,6 +172,24 @@ specific commands:
172
172
  The list accepts command classes or command names. Sending another command throws
173
173
  `SimSdkCommandNotInterceptedError`.
174
174
 
175
+ ## Abort a send
176
+
177
+ An intercepted client honours the `abortSignal` option of `send`, as the SDK's own HTTP handler does:
178
+
179
+ `await client.send(command, { abortSignal: AbortSignal.timeout(5000) })`
180
+
181
+ A signal that has already fired rejects the send before it reaches the simulation. A signal that fires
182
+ while the simulated operation is pending rejects the send then. A Lambda handler sleeping on the
183
+ simulation's clock is one operation that stays pending.
184
+
185
+ Either way the send rejects with an `AbortError` shaped like the one the SDK throws. Its message is
186
+ `Request aborted` and its `cause` is the signal's reason (a `TimeoutError` for
187
+ `AbortSignal.timeout`). Match it by `error.name`.
188
+
189
+ A send carrying a signal waits one turn of the host's timers before it reaches the simulation. That
190
+ turn stands in for the request's time on the network. A timeout of 0 or 1 milliseconds set before the
191
+ send aborts it, as it would a request to AWS.
192
+
175
193
  ## Intercept the DynamoDB document client
176
194
 
177
195
  The DynamoDB document client accepts plain JavaScript values. Intercept the document client object,
@@ -282,6 +300,11 @@ service throws `SimSdkUnknownServiceError`.
282
300
  - Simulated errors have SDK-shaped `name` and `$metadata` fields. They are separate classes from the
283
301
  SDK exceptions, so match them by `error.name` instead of `instanceof`.
284
302
  - The callback form of `send(command, callback)` is not supported. Use the promise form.
303
+ - An aborted send stops waiting for its answer. The simulated operation it started still runs to the
304
+ end, as a request AWS has already received does.
305
+ - Most simulated operations answer within one turn of the host's timers. A timeout longer than a few
306
+ milliseconds only fires during an operation that waits, such as a sleeping Lambda handler. A
307
+ simulated Bedrock answer cannot be delayed.
285
308
  - Yulin reads the `marshallOptions` in
286
309
  `DynamoDBDocumentClient.from(client, { marshallOptions, unmarshallOptions })` and ignores the
287
310
  `unmarshallOptions`. A stored value comes back the way a document client built with no options of
@@ -303,6 +303,64 @@ regardless of `ActionAfterCompletion`.
303
303
  enabling it picks up from the next due instant. What it missed is never replayed. An update that
304
304
  changes the expression reschedules from the new one.
305
305
 
306
+ ### Flexible time windows
307
+
308
+ `FlexibleTimeWindow: { Mode: "FLEXIBLE", MaximumWindowInMinutes: n }` delays each invocation by up
309
+ to `n` minutes. The schedule still falls due at the times its expression gives. Each occurrence then
310
+ invokes its target at a moment drawn inside `[due, due + n minutes)`. The moment is never before the
311
+ due time and always before the window closes.
312
+
313
+ The draw is made separately for every occurrence. A test that advances the clock past the end of an
314
+ occurrence's window sees exactly one invocation for it, whatever was drawn. A test that stops inside
315
+ the window may or may not see it. Assert after the window has closed.
316
+
317
+ The draws come from the `random` source the `SimAws` was built with. It defaults to the host's
318
+ `Math.random`, and the moments differ from run to run. Pass a `SimSeededRandom` to draw the same
319
+ moments every time (to reproduce a failure, for example).
320
+
321
+ ```typescript sim-scheduler-flexible-window
322
+ /**
323
+ * A schedule invoking its target up to fifteen minutes after each hour.
324
+ */
325
+
326
+ import {
327
+ CreateScheduleCommand,
328
+ GetScheduleCommand,
329
+ } from "@aws-sdk/client-scheduler";
330
+
331
+ import { SimAws, SimSeededRandom } from "@kensio/yulin";
332
+
333
+ // The same seed draws the same invocation moments on every run.
334
+ const simAws = new SimAws({ random: new SimSeededRandom(2026) });
335
+
336
+ await simAws.scheduler().createSchedule(
337
+ new CreateScheduleCommand({
338
+ Name: "hourly-digest",
339
+ ScheduleExpression: "cron(0 * * * ? *)",
340
+ FlexibleTimeWindow: { Mode: "FLEXIBLE", MaximumWindowInMinutes: 15 },
341
+ Target: {
342
+ Arn: "arn:aws:lambda:us-east-1:888888888888:function:digest",
343
+ RoleArn: "arn:aws:iam::888888888888:role/SchedulerRole",
344
+ },
345
+ }),
346
+ );
347
+
348
+ const described = await simAws
349
+ .scheduler()
350
+ .getSchedule(new GetScheduleCommand({ Name: "hourly-digest" }));
351
+
352
+ console.log(described.FlexibleTimeWindow);
353
+ // { Mode: "FLEXIBLE", MaximumWindowInMinutes: 15 }
354
+ ```
355
+
356
+ A `FLEXIBLE` window needs `MaximumWindowInMinutes`, a whole number from 1 to 1440. An `OFF` window
357
+ must leave it out. Either mistake is a `ValidationException`. `GetSchedule` reports the window the
358
+ schedule was created with.
359
+
360
+ A schedule deleted or replaced while an occurrence is waiting inside its window does not invoke for
361
+ that occurrence. A one-time schedule with `ActionAfterCompletion: "DELETE"` is removed once its
362
+ delayed invocation has been made.
363
+
306
364
  ## Running an ECS task on a schedule
307
365
 
308
366
  A target whose ARN names an ECS cluster runs a [simulated ECS](https://yulinsim.dev/services/ecs/)
@@ -812,6 +870,8 @@ Resources of the same stack.
812
870
  authorized against that role's own policies. Lambda targets use asynchronous Event invocation.
813
871
  - ECS targets, running a simulated task as the execution role, with the task definition and
814
872
  `TaskCount` from `EcsParameters` and container overrides from the target's `Input`.
873
+ - `FlexibleTimeWindow` in both modes, with each `FLEXIBLE` invocation drawn inside its window from
874
+ the simulation's `random` source.
815
875
  - `ActionAfterCompletion`, and `deliveryFailures` for invocations that did not happen.
816
876
  - Target retry policies driven by simulated time, and standard SQS dead-letter queues carrying the
817
877
  original input and Scheduler diagnostic attributes.
@@ -829,8 +889,8 @@ Resources of the same stack.
829
889
 
830
890
  - A schedule only fires while a test advances the simulation's clock. The host's clock drives none
831
891
  of it, and a simulation left alone in real time never fires however long it is left.
832
- - Firing is exact and exactly once. Real Scheduler invokes within a minute of the due time, and its
833
- promise is at-least-once.
892
+ - Firing is exact and exactly once with the window `OFF`. Real Scheduler invokes within a minute of
893
+ the due time, and its promise is at-least-once.
834
894
  - Retry delays are deterministic powers of two seconds. Real Scheduler uses exponential backoff
835
895
  without publishing an exact sequence. The fixed sequence lets a test advance to a known retry
836
896
  instant.
@@ -844,9 +904,12 @@ Resources of the same stack.
844
904
  real Scheduler holds the group in `DELETING` until they have gone.
845
905
  - The `default` schedule group cannot be deleted. AWS leaves the answer to that request
846
906
  undocumented.
847
- - `FlexibleTimeWindow` with `Mode: "FLEXIBLE"` is refused. Real Scheduler invokes the target at an
848
- unpredictable moment inside the window, and firing at the exact due time instead would let a test
849
- rely on timing AWS leaves unpromised.
907
+ - A flexible window draws each invocation moment uniformly, to the millisecond. AWS documents only
908
+ that the target is invoked inside the window after the scheduled time, and publishes no
909
+ distribution.
910
+ - The wording of the refusals for a `FLEXIBLE` window with no `MaximumWindowInMinutes`, and an `OFF`
911
+ window with one, is Yulin's own. AWS refuses both and does not document the message. The range
912
+ refusals use the API's constraint wording.
850
913
  - `ScheduleExpressionTimezone` is read from the host's own timezone data through `Intl`, so a zone
851
914
  the host has never heard of is refused even where AWS would take it. A schedule that names none
852
915
  runs in UTC.
@@ -247,6 +247,24 @@ Yulin uses the clock for:
247
247
  - `Date`, global timers, and invocation deadlines inside a simulated Lambda invocation
248
248
  - HTTP response `Date` headers
249
249
 
250
+ ## Values AWS leaves to chance
251
+
252
+ Some AWS behaviour is unpredictable by design. An EventBridge Scheduler
253
+ [flexible time window](https://yulinsim.dev/services/scheduler/#flexible-time-windows) invokes its
254
+ target at a moment AWS chooses inside the window. Yulin draws those values from the `random` option
255
+ of `SimAws`, which defaults to the host's `Math.random`.
256
+
257
+ Pass a `SimSeededRandom` to draw the same values on every run:
258
+
259
+ ```typescript
260
+ import { SimAws, SimSeededRandom } from "@kensio/yulin";
261
+
262
+ const simAws = new SimAws({ random: new SimSeededRandom(2026) });
263
+ ```
264
+
265
+ A test that advances the clock past the end of a window passes whatever was drawn. A seed is for
266
+ reproducing a run (one that failed, for example).
267
+
250
268
  ## Limitations
251
269
 
252
270
  - Scheduled [EventBridge rules](https://yulinsim.dev/services/eventbridge/#rules-that-fire-on-a-schedule)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kensio/yulin",
3
- "version": "1.21.25",
3
+ "version": "1.21.26",
4
4
  "description": "AWS system behaviour simulation for isolated unit testing",
5
5
  "repository": "https://github.com/KensioSoftware/yulin",
6
6
  "homepage": "https://yulinsim.dev/",
@@ -299,7 +299,7 @@
299
299
  "hyparquet": "^1.29.2",
300
300
  "hyparquet-writer": "^0.16.9",
301
301
  "node-sql-parser": "^5.4.0",
302
- "oxfmt": "^0.70.0",
302
+ "oxfmt": "^0.71.0",
303
303
  "oxlint": "^1.77.0",
304
304
  "oxlint-tsgolint": "^7.0.2001",
305
305
  "semantic-release": "25.0.9",