@reventlessdev/reventless-aws 3.0.0-alpha.381 → 3.0.0-alpha.383

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.
@@ -224,6 +224,9 @@ describe("AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandler", () =>
224
224
  steps->Array.push("phase1")
225
225
  },
226
226
  phase2: async _publish => steps->Array.push("phase2"),
227
+ schedules: Dict.make(),
228
+ takeScheduleChanges: () => [],
229
+ rebuildSchedules: _ => (),
227
230
  }
228
231
  let context: Reventless.AutomationSlice.context = {
229
232
  environment: "test",
@@ -250,6 +253,50 @@ describe("AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandler", () =>
250
253
  // the reloaded backlog unprocessed until another invocation.
251
254
  expect(steps)->toEqual(["restore", "phase1", "sync", "phase2", "sync"])
252
255
  })
256
+
257
+ // The rows are the truth: a platform told before they are stored could hold a
258
+ // schedule no row knows of, if the store write then failed.
259
+ test("stores a changed schedule's rows before telling the platform", async () => {
260
+ let steps = []
261
+ let daily = Reventless.Schedule.Calendar({calendar: Daily({hour: 6, minute: 0})})
262
+ let callback: AutomationSliceEntryPoint_Ops.automationCallback = {
263
+ todoItems: Dict.make(),
264
+ phase1: (_jsons, _ctx) => steps->Array.push("phase1"),
265
+ phase2: async _publish => steps->Array.push("phase2"),
266
+ schedules: Dict.make(),
267
+ takeScheduleChanges: () => [Put({key: "r1", time: daily})],
268
+ rebuildSchedules: _ => (),
269
+ }
270
+ let schedules: AutomationSliceEntryPoint_Ops.schedulesStep = {
271
+ load: async () => steps->Array.push("load schedules"),
272
+ sync: async () => steps->Array.push("sync schedules"),
273
+ platform: {
274
+ put: async (~key, _) => steps->Array.push(`put ${key}`),
275
+ removeName: async name => steps->Array.push(`remove ${name}`),
276
+ names: async () => [],
277
+ nameOf: key => key,
278
+ },
279
+ }
280
+ let handler = AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandler(
281
+ ~context={environment: "test", platformName: "P", pluginName: "Pl", sliceName: "S"},
282
+ ~callback,
283
+ ~publishJsons=noopPublish,
284
+ ~syncTodoItems=async () => steps->Array.push("sync"),
285
+ ~loadTodoItems=async () => steps->Array.push("restore"),
286
+ ~schedules=() => Some(schedules),
287
+ )
288
+ await Stream.fromIterable([str("e")])->handler->Effect.runPromise
289
+ expect(steps)->toEqual([
290
+ "restore",
291
+ "load schedules",
292
+ "phase1",
293
+ "sync",
294
+ "sync schedules",
295
+ "put r1",
296
+ "phase2",
297
+ "sync",
298
+ ])
299
+ })
253
300
  })
254
301
 
255
302
  describe("AutomationSliceEntryPoint_Ops.runSweeps", () => {
@@ -260,6 +307,9 @@ describe("AutomationSliceEntryPoint_Ops.runSweeps", () => {
260
307
  registered: dummyRegistered,
261
308
  sweep: run,
262
309
  comp,
310
+ name: comp,
311
+ tick: None,
312
+ reconcile: None,
263
313
  }
264
314
 
265
315
  test("sweeps every slice", async () => {
@@ -285,6 +335,47 @@ describe("AutomationSliceEntryPoint_Ops.runSweeps", () => {
285
335
  })
286
336
  })
287
337
 
338
+ describe("AutomationSliceEntryPoint_Ops.runTick", () => {
339
+ let dummyRegistered: StreamRoutedEntryPoint_Ops.registeredHandler = {
340
+ handler: (_event, _context) => Effect.succeed(),
341
+ }
342
+ let ticks = []
343
+ let slice = (~name, ~tick): AutomationSliceEntryPoint_Ops.builtSlice => {
344
+ registered: dummyRegistered,
345
+ sweep: async () => (),
346
+ comp: name,
347
+ name,
348
+ tick,
349
+ reconcile: None,
350
+ }
351
+ let slices = [
352
+ slice(~name="Outbound", ~tick=None),
353
+ slice(~name="DemandReport", ~tick=Some(async (~key, ~at) => ticks->Array.push((key, at)))),
354
+ ]
355
+
356
+ test("delivers the scheduled time to the named slice", async () => {
357
+ await AutomationSliceEntryPoint_Ops.runTick(
358
+ slices,
359
+ {slice: "DemandReport", key: "default", at: "2026-10-16T06:00:00Z"},
360
+ )
361
+ expect(ticks)->toEqual([("default", Date.fromString("2026-10-16T06:00:00Z")->Date.getTime)])
362
+ })
363
+
364
+ // A throw makes the Scheduler retry, and leaves a log line to find.
365
+ test("refuses a slice it does not host, or one that maps no Clock", async () => {
366
+ let refused = async invocation =>
367
+ switch await AutomationSliceEntryPoint_Ops.runTick(slices, invocation) {
368
+ | () => false
369
+ | exception _ => true
370
+ }
371
+ expect(await refused({slice: "Nobody", key: "default", at: "2026-10-16T06:00:00Z"}))->toBe(true)
372
+ expect(await refused({slice: "Outbound", key: "default", at: "2026-10-16T06:00:00Z"}))->toBe(
373
+ true,
374
+ )
375
+ expect(await refused({slice: "DemandReport", key: "default", at: "soon"}))->toBe(true)
376
+ })
377
+ })
378
+
288
379
  // Consumed-event fixture for the outbound decode: one consumed variant with a
289
380
  // payload, one payload-less, decoded from a source that also emits types this
290
381
  // slice does not consume.
@@ -328,10 +328,16 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandl
328
328
  let callback_phase2 = async _publish => {
329
329
  steps.push("phase2");
330
330
  };
331
+ let callback_schedules = {};
332
+ let callback_takeScheduleChanges = () => [];
333
+ let callback_rebuildSchedules = param => {};
331
334
  let callback = {
332
335
  todoItems: callback_todoItems,
333
336
  phase1: callback_phase1,
334
- phase2: callback_phase2
337
+ phase2: callback_phase2,
338
+ schedules: callback_schedules,
339
+ takeScheduleChanges: callback_takeScheduleChanges,
340
+ rebuildSchedules: callback_rebuildSchedules
335
341
  };
336
342
  let handler = AutomationSliceEntryPoint_Ops$ReventlessAws.makeAutomationJsonEventsHandler({
337
343
  environment: "test",
@@ -342,7 +348,7 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandl
342
348
  steps.push("sync");
343
349
  }, async () => {
344
350
  steps.push("restore");
345
- });
351
+ }, undefined);
346
352
  let envelope = Object.fromEntries([
347
353
  [
348
354
  "meta",
@@ -373,6 +379,80 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.makeAutomationJsonEventsHandl
373
379
  "sync"
374
380
  ]);
375
381
  });
382
+ globalThis.test("stores a changed schedule's rows before telling the platform", async () => {
383
+ let steps = [];
384
+ let callback_todoItems = {};
385
+ let callback_phase1 = (_jsons, _ctx) => {
386
+ steps.push("phase1");
387
+ };
388
+ let callback_phase2 = async _publish => {
389
+ steps.push("phase2");
390
+ };
391
+ let callback_schedules = {};
392
+ let callback_takeScheduleChanges = () => [{
393
+ TAG: "Put",
394
+ key: "r1",
395
+ time: {
396
+ TAG: "Calendar",
397
+ calendar: {
398
+ TAG: "Daily",
399
+ hour: 6,
400
+ minute: 0
401
+ }
402
+ }
403
+ }];
404
+ let callback_rebuildSchedules = param => {};
405
+ let callback = {
406
+ todoItems: callback_todoItems,
407
+ phase1: callback_phase1,
408
+ phase2: callback_phase2,
409
+ schedules: callback_schedules,
410
+ takeScheduleChanges: callback_takeScheduleChanges,
411
+ rebuildSchedules: callback_rebuildSchedules
412
+ };
413
+ let schedules_load = async () => {
414
+ steps.push("load schedules");
415
+ };
416
+ let schedules_sync = async () => {
417
+ steps.push("sync schedules");
418
+ };
419
+ let schedules_platform = {
420
+ put: async (key, param) => {
421
+ steps.push(`put ` + key);
422
+ },
423
+ removeName: async name => {
424
+ steps.push(`remove ` + name);
425
+ },
426
+ names: async () => [],
427
+ nameOf: key => key
428
+ };
429
+ let schedules = {
430
+ load: schedules_load,
431
+ sync: schedules_sync,
432
+ platform: schedules_platform
433
+ };
434
+ let handler = AutomationSliceEntryPoint_Ops$ReventlessAws.makeAutomationJsonEventsHandler({
435
+ environment: "test",
436
+ platformName: "P",
437
+ pluginName: "Pl",
438
+ sliceName: "S"
439
+ }, callback, noopPublish, async () => {
440
+ steps.push("sync");
441
+ }, async () => {
442
+ steps.push("restore");
443
+ }, () => schedules);
444
+ await Effect.runPromise(handler(Stream.fromIterable(["e"])));
445
+ globalThis.expect(steps).toEqual([
446
+ "restore",
447
+ "load schedules",
448
+ "phase1",
449
+ "sync",
450
+ "sync schedules",
451
+ "put r1",
452
+ "phase2",
453
+ "sync"
454
+ ]);
455
+ });
376
456
  });
377
457
 
378
458
  globalThis.describe("AutomationSliceEntryPoint_Ops.runSweeps", () => {
@@ -380,23 +460,23 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.runSweeps", () => {
380
460
  let dummyRegistered = {
381
461
  handler: dummyRegistered_handler
382
462
  };
463
+ let sliceThat = (comp, run) => ({
464
+ registered: dummyRegistered,
465
+ sweep: run,
466
+ comp: comp,
467
+ name: comp,
468
+ tick: undefined,
469
+ reconcile: undefined
470
+ });
383
471
  globalThis.test("sweeps every slice", async () => {
384
472
  let swept = [];
385
473
  await AutomationSliceEntryPoint_Ops$ReventlessAws.runSweeps([
386
- {
387
- registered: dummyRegistered,
388
- sweep: async () => {
389
- swept.push("a");
390
- },
391
- comp: "A"
392
- },
393
- {
394
- registered: dummyRegistered,
395
- sweep: async () => {
396
- swept.push("b");
397
- },
398
- comp: "B"
399
- }
474
+ sliceThat("A", async () => {
475
+ swept.push("a");
476
+ }),
477
+ sliceThat("B", async () => {
478
+ swept.push("b");
479
+ })
400
480
  ]);
401
481
  globalThis.expect(swept).toEqual([
402
482
  "a",
@@ -406,25 +486,13 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.runSweeps", () => {
406
486
  globalThis.test("a throwing slice does not stop the others", async () => {
407
487
  let swept = [];
408
488
  await AutomationSliceEntryPoint_Ops$ReventlessAws.runSweeps([
409
- {
410
- registered: dummyRegistered,
411
- sweep: async () => {
412
- swept.push("a");
413
- },
414
- comp: "A"
415
- },
416
- {
417
- registered: dummyRegistered,
418
- sweep: async () => Stdlib_JsError.throwWithMessage("gateway down"),
419
- comp: "Boom"
420
- },
421
- {
422
- registered: dummyRegistered,
423
- sweep: async () => {
424
- swept.push("c");
425
- },
426
- comp: "C"
427
- }
489
+ sliceThat("A", async () => {
490
+ swept.push("a");
491
+ }),
492
+ sliceThat("Boom", async () => Stdlib_JsError.throwWithMessage("gateway down")),
493
+ sliceThat("C", async () => {
494
+ swept.push("c");
495
+ })
428
496
  ]);
429
497
  globalThis.expect(swept).toEqual([
430
498
  "a",
@@ -433,6 +501,74 @@ globalThis.describe("AutomationSliceEntryPoint_Ops.runSweeps", () => {
433
501
  });
434
502
  });
435
503
 
504
+ globalThis.describe("AutomationSliceEntryPoint_Ops.runTick", () => {
505
+ let dummyRegistered_handler = (_event, _context) => Effect.succeed();
506
+ let dummyRegistered = {
507
+ handler: dummyRegistered_handler
508
+ };
509
+ let ticks = [];
510
+ let slice = (name, tick) => ({
511
+ registered: dummyRegistered,
512
+ sweep: async () => {},
513
+ comp: name,
514
+ name: name,
515
+ tick: tick,
516
+ reconcile: undefined
517
+ });
518
+ let slices = [
519
+ {
520
+ registered: dummyRegistered,
521
+ sweep: async () => {},
522
+ comp: "Outbound",
523
+ name: "Outbound",
524
+ tick: undefined,
525
+ reconcile: undefined
526
+ },
527
+ slice("DemandReport", async (key, at) => {
528
+ ticks.push([
529
+ key,
530
+ at
531
+ ]);
532
+ })
533
+ ];
534
+ globalThis.test("delivers the scheduled time to the named slice", async () => {
535
+ await AutomationSliceEntryPoint_Ops$ReventlessAws.runTick(slices, {
536
+ slice: "DemandReport",
537
+ key: "default",
538
+ at: "2026-10-16T06:00:00Z"
539
+ });
540
+ globalThis.expect(ticks).toEqual([[
541
+ "default",
542
+ new Date("2026-10-16T06:00:00Z").getTime()
543
+ ]]);
544
+ });
545
+ globalThis.test("refuses a slice it does not host, or one that maps no Clock", async () => {
546
+ let refused = async invocation => {
547
+ try {
548
+ await AutomationSliceEntryPoint_Ops$ReventlessAws.runTick(slices, invocation);
549
+ return false;
550
+ } catch (exn) {
551
+ return true;
552
+ }
553
+ };
554
+ globalThis.expect(await refused({
555
+ slice: "Nobody",
556
+ key: "default",
557
+ at: "2026-10-16T06:00:00Z"
558
+ })).toBe(true);
559
+ globalThis.expect(await refused({
560
+ slice: "Outbound",
561
+ key: "default",
562
+ at: "2026-10-16T06:00:00Z"
563
+ })).toBe(true);
564
+ globalThis.expect(await refused({
565
+ slice: "DemandReport",
566
+ key: "default",
567
+ at: "soon"
568
+ })).toBe(true);
569
+ });
570
+ });
571
+
436
572
  let outboundEventSchema = Sury.union([
437
573
  Sury.$schema(s => ({
438
574
  TAG: "OrderPlaced",
@@ -57,4 +57,34 @@ describe("ScheduledPublisher_EventBridgeScheduler_Runtime", () => {
57
57
  "arn:aws:scheduler:*:123456789012:schedule/SchedulerRole-*/*",
58
58
  )
59
59
  })
60
+
61
+ testSync("a schedule group's tags carry no empty value, which AWS refuses", () => {
62
+ let tags = Dict.fromArray([("Name", "SchedulerScheduleGroup"), ("reventless:plugin", "")])
63
+ expect(tags->AWS.Tags.withoutEmpty->Dict.toArray)->toEqual([("Name", "SchedulerScheduleGroup")])
64
+ })
65
+
66
+ testSync("names an automation's schedule <slice>.<key>, or hashes a key it would refuse", () => {
67
+ expect(Runtime.tickScheduleName(~slice="MarketingDigest", ~key="r1"))->toBe(
68
+ "MarketingDigest.r1",
69
+ )
70
+ let hashed = Runtime.tickScheduleName(~slice="MarketingDigest", ~key="customer:42")
71
+ expect((hashed->String.startsWith("MarketingDigest."), hashed->String.length))->toEqual((
72
+ true,
73
+ 16 + 16,
74
+ ))
75
+ let long = "A"->String.repeat(60)
76
+ let cut = Runtime.tickScheduleName(~slice=long, ~key="default")
77
+ expect((cut->String.length, cut->Runtime.isSliceSchedule(~slice=long)))->toEqual((57, true))
78
+ })
79
+
80
+ testSync("tells a slice's schedules from another slice's sharing its prefix", () => {
81
+ expect("Digest.r1"->Runtime.isSliceSchedule(~slice="Digest"))->toBe(true)
82
+ expect("DigestWeekly.r1"->Runtime.isSliceSchedule(~slice="Digest"))->toBe(false)
83
+ })
84
+
85
+ testSync("invokes the automation Lambda with the slice, the key and the scheduled time", () => {
86
+ expect(
87
+ Runtime.tickInput(~slice="MarketingDigest", ~key="r1"),
88
+ )->toBe(`{"reventlessTick":{"slice":"MarketingDigest","key":"r1","at":"<aws.scheduler.scheduled-time>"}}`)
89
+ })
60
90
  })
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as AWS_Tags$ReventlessAws from "../src/adapter/AWS_Tags.res.mjs";
4
5
  import * as ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws from "../src/adapter/ScheduledPublisher/ScheduledPublisher_EventBridgeScheduler_Runtime.res.mjs";
5
6
 
6
7
  let roleArn = "arn:aws:iam::123456789012:role/SchedulerRole-1a2b3c4";
@@ -83,6 +84,49 @@ globalThis.describe("ScheduledPublisher_EventBridgeScheduler_Runtime", () => {
83
84
  globalThis.expect(ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws.schedulesOfRoleArn(roleArn)).toBe("arn:aws:scheduler:*:123456789012:schedule/SchedulerRole-1a2b3c4/*");
84
85
  globalThis.expect(ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws.schedulesOfAccount("123456789012")).toBe("arn:aws:scheduler:*:123456789012:schedule/SchedulerRole-*/*");
85
86
  });
87
+ globalThis.test("a schedule group's tags carry no empty value, which AWS refuses", () => {
88
+ let tags = Object.fromEntries([
89
+ [
90
+ "Name",
91
+ "SchedulerScheduleGroup"
92
+ ],
93
+ [
94
+ "reventless:plugin",
95
+ ""
96
+ ]
97
+ ]);
98
+ globalThis.expect(Object.entries(AWS_Tags$ReventlessAws.withoutEmpty(tags))).toEqual([[
99
+ "Name",
100
+ "SchedulerScheduleGroup"
101
+ ]]);
102
+ });
103
+ globalThis.test("names an automation's schedule <slice>.<key>, or hashes a key it would refuse", () => {
104
+ globalThis.expect(ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws.tickScheduleName("MarketingDigest", "r1")).toBe("MarketingDigest.r1");
105
+ let hashed = ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws.tickScheduleName("MarketingDigest", "customer:42");
106
+ globalThis.expect([
107
+ hashed.startsWith("MarketingDigest."),
108
+ hashed.length
109
+ ]).toEqual([
110
+ true,
111
+ 32
112
+ ]);
113
+ let long = "A".repeat(60);
114
+ let cut = ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws.tickScheduleName(long, "default");
115
+ globalThis.expect([
116
+ cut.length,
117
+ ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws.isSliceSchedule(cut, long)
118
+ ]).toEqual([
119
+ 57,
120
+ true
121
+ ]);
122
+ });
123
+ globalThis.test("tells a slice's schedules from another slice's sharing its prefix", () => {
124
+ globalThis.expect(ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws.isSliceSchedule("Digest.r1", "Digest")).toBe(true);
125
+ globalThis.expect(ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws.isSliceSchedule("DigestWeekly.r1", "Digest")).toBe(false);
126
+ });
127
+ globalThis.test("invokes the automation Lambda with the slice, the key and the scheduled time", () => {
128
+ globalThis.expect(ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws.tickInput("MarketingDigest", "r1")).toBe(`{"reventlessTick":{"slice":"MarketingDigest","key":"r1","at":"<aws.scheduler.scheduled-time>"}}`);
129
+ });
86
130
  });
87
131
 
88
132
  let Runtime;