@reventlessdev/reventless-aws 3.0.0-alpha.382 → 3.0.0-alpha.384

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,33 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.384 (2026-10-16)
7
+
8
+ * fix(auth)!: only operators read the framework's own stores ([a2fd202](https://github.com/ReventlessDev/reventless-core/commit/a2fd2022b6876dae855b185df7f8263353fc971d))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * a caller outside the elevated groups is refused the
13
+ framework's to-do, schedule and audit stores. On AWS, a roles-gated
14
+ view's …ByIds, …Refs, …Items and …By<Index> fields refuse callers
15
+ outside its roles.
16
+
17
+
18
+
19
+ # 3.0.0-alpha.383 (2026-10-16)
20
+
21
+ * feat!(automation): schedules set by events ([53bc436](https://github.com/ReventlessDev/reventless-core/commit/53bc4363dffce81ef36475728bf0dae704716021))
22
+ * feat!(automation): declared schedules tick an automation ([420acd2](https://github.com/ReventlessDev/reventless-core/commit/420acd2233aadf700141a31d9d3e0fdc8074bef7))
23
+
24
+ ### BREAKING CHANGES
25
+
26
+ * a hand-written AutomationSlice.Mapping.Make the PPX does
27
+ not see must define `let reschedule = _ => []`.
28
+ * a hand-written AutomationSlice spec the PPX does not see must
29
+ declare `let schedule = None`.
30
+
31
+
32
+
6
33
  # 3.0.0-alpha.382 (2026-10-16)
7
34
 
8
35
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.382",
3
+ "version": "3.0.0-alpha.384",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -22,19 +22,19 @@
22
22
  "sury": "11.0.0",
23
23
  "uuid": "^13.0.0",
24
24
  "yaml": "^2.8.3",
25
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.31",
26
- "@reventlessdev/rescript-jest": "1.0.0-alpha.12",
27
- "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.28",
25
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.32",
28
26
  "@reventlessdev/rescript-node": "2.0.0-alpha.22",
27
+ "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.29",
28
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.12",
29
29
  "@reventlessdev/rescript-effect": "1.0.0-alpha.0",
30
30
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.23",
31
- "@reventlessdev/rescript-web": "1.0.0-alpha.7",
32
- "@reventlessdev/reventless-core": "3.0.0-alpha.295",
33
31
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
34
- "@reventlessdev/reventless-infra": "3.0.0-alpha.192",
35
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.159",
36
- "@reventlessdev/reventless-spec": "3.0.0-alpha.164",
37
- "@reventlessdev/reventless-interop": "3.0.0-alpha.40"
32
+ "@reventlessdev/rescript-web": "1.0.0-alpha.7",
33
+ "@reventlessdev/reventless-core": "3.0.0-alpha.297",
34
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.194",
35
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.161",
36
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.40",
37
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.166"
38
38
  },
39
39
  "devDependencies": {
40
40
  "rescript": "12.3.1",
package/src/Platform.res CHANGED
@@ -815,12 +815,16 @@ module MakeWithConfig = (
815
815
  | None => resolveHookedApi()
816
816
  }
817
817
 
818
+ // Set by `makeScheduler`, before any plugin builds; the automation Lambda's
819
+ // finalizer, inside `hooks`, reads it for the stack's schedule group.
820
+ let schedulerRoleUrnRef = ref(Pulumi.Output.make(""))
821
+
818
822
  let hooks: ReventlessCore.Plugin_Helpers.platformHooks = {
819
823
  // AWS uses Interstack for admin extension points — leave ref at empty dict.
820
824
  adminExtensionPoints: ref(Pulumi.Output.make(Dict.make())),
821
825
  // Platform context — populated by makePlatform/deployPlugin before plugin build.
822
826
  scheduler: ref(None),
823
- schedulerRoleUrn: ref(Pulumi.Output.make("")),
827
+ schedulerRoleUrn: schedulerRoleUrnRef,
824
828
  api: hooksApiRef,
825
829
  apiRole: hooksApiRoleRef,
826
830
  adminApi: hooksAdminApiRef,
@@ -920,7 +924,10 @@ module MakeWithConfig = (
920
924
  onDcbSlicesCreated: dcbEventLogUnknown => {
921
925
  let dcbEventLog = dcbEventLogUnknown->asDcbEventLog
922
926
  StateViewSliceRuntime_Builder_Single.finishWithDcbEventLog(dcbEventLog)
923
- AutomationSliceRuntime_Builder_Single.finishWithDcbEventLog(dcbEventLog)
927
+ AutomationSliceRuntime_Builder_Single.finishWithDcbEventLog(
928
+ ~schedulerRoleArn=schedulerRoleUrnRef.contents,
929
+ dcbEventLog,
930
+ )
924
931
  },
925
932
  // Heartbeat EP channel hook — extracts SQS queue URL for heartbeat Lambda handler
926
933
  // and records the calling plugin's id so the Lambda runtime can emit Connect commands
@@ -544,6 +544,9 @@ function MakeWithConfig(Config) {
544
544
  return resolveHookedApi();
545
545
  }
546
546
  };
547
+ let schedulerRoleUrnRef = {
548
+ contents: Pulumi.output("")
549
+ };
547
550
  let hooks_subscriptionInfraHook = Stdlib_Option.map(domainEventsApiOpt, eventsApi => (params => {
548
551
  let allEventTopics = params.allEventTopics;
549
552
  let allQueryDbs = params.allQueryDbs;
@@ -604,7 +607,7 @@ function MakeWithConfig(Config) {
604
607
  };
605
608
  let hooks_onDcbSlicesCreated = dcbEventLogUnknown => {
606
609
  StateViewSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(dcbEventLogUnknown);
607
- AutomationSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(dcbEventLogUnknown);
610
+ AutomationSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(schedulerRoleUrnRef.contents, dcbEventLogUnknown);
608
611
  };
609
612
  let hooks_onHeartbeatEpChannelAvailable = (remoteChannelUnknown, pluginId, heartbeatInterval) => {
610
613
  let resource = remoteChannelUnknown.resources[0];
@@ -620,9 +623,6 @@ function MakeWithConfig(Config) {
620
623
  let hooks_scheduler = {
621
624
  contents: undefined
622
625
  };
623
- let hooks_schedulerRoleUrn = {
624
- contents: Pulumi.output("")
625
- };
626
626
  let hooks_deployTarget = {
627
627
  contents: "Domain"
628
628
  };
@@ -638,7 +638,7 @@ function MakeWithConfig(Config) {
638
638
  onHeartbeatEpChannelAvailable: hooks_onHeartbeatEpChannelAvailable,
639
639
  adminExtensionPoints: hooks_adminExtensionPoints,
640
640
  scheduler: hooks_scheduler,
641
- schedulerRoleUrn: hooks_schedulerRoleUrn,
641
+ schedulerRoleUrn: schedulerRoleUrnRef,
642
642
  api: hooksApiRef,
643
643
  apiRole: hooksApiRoleRef,
644
644
  adminApi: hooksAdminApiRef,
@@ -713,7 +713,7 @@ function MakeWithConfig(Config) {
713
713
  let makeScheduler = () => {
714
714
  let component = Scheduler$ReventlessAws.make(undefined);
715
715
  let outputs = Component$ReventlessCore.outputs(component);
716
- hooks_schedulerRoleUrn.contents = outputs.resource.urn;
716
+ schedulerRoleUrnRef.contents = outputs.resource.urn;
717
717
  return Component$ReventlessCore.operations(component);
718
718
  };
719
719
  let provisionPgChangeFeedRelay = () => {
@@ -884,7 +884,7 @@ function MakeWithConfig(Config) {
884
884
  } else {
885
885
  pluginReadModelTableName = undefined;
886
886
  }
887
- PluginExtensionPointRuntime_Builder$ReventlessAws.registerPluginExtensionPoint(pluginReadModelTableName, hooks_schedulerRoleUrn.contents, undefined);
887
+ PluginExtensionPointRuntime_Builder$ReventlessAws.registerPluginExtensionPoint(pluginReadModelTableName, schedulerRoleUrnRef.contents, undefined);
888
888
  let pluginEpEventTopicArn = Output$Pulumi.flatMap(admin.extensionPointsOutputs, eps => {
889
889
  let ep = eps.find(ep => ep.name === PluginExtensionPointSpec$ReventlessInfra.name);
890
890
  if (ep !== undefined) {
@@ -900,7 +900,7 @@ function MakeWithConfig(Config) {
900
900
  return Pulumi.output("NOT_AVAILABLE");
901
901
  }
902
902
  });
903
- PluginRuntime_Builder$ReventlessAws.registerConfig(pluginEpEventTopicArn, pluginReadModelTableName, hooks_schedulerRoleUrn.contents, undefined, undefined, domainApiId, Config.cloner, undefined);
903
+ PluginRuntime_Builder$ReventlessAws.registerConfig(pluginEpEventTopicArn, pluginReadModelTableName, schedulerRoleUrnRef.contents, undefined, undefined, domainApiId, Config.cloner, undefined);
904
904
  let offloadProtection = Util_StoreLayout$ReventlessAws.protectionOfDeployedStack();
905
905
  let offloadBucket = new (Aws.s3.Bucket)("reventless-offload", {
906
906
  forceDestroy: offloadProtection === "Unprotected",
@@ -1960,6 +1960,9 @@ function Make($star) {
1960
1960
  return resolveHookedApi();
1961
1961
  }
1962
1962
  };
1963
+ let schedulerRoleUrnRef = {
1964
+ contents: Pulumi.output("")
1965
+ };
1963
1966
  let hooks_subscriptionInfraHook = Stdlib_Option.map(domainEventsApiOpt, eventsApi => (params => {
1964
1967
  let allEventTopics = params.allEventTopics;
1965
1968
  let allQueryDbs = params.allQueryDbs;
@@ -2020,7 +2023,7 @@ function Make($star) {
2020
2023
  };
2021
2024
  let hooks_onDcbSlicesCreated = dcbEventLogUnknown => {
2022
2025
  StateViewSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(dcbEventLogUnknown);
2023
- AutomationSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(dcbEventLogUnknown);
2026
+ AutomationSliceRuntime_Builder_Single$ReventlessAws.finishWithDcbEventLog(schedulerRoleUrnRef.contents, dcbEventLogUnknown);
2024
2027
  };
2025
2028
  let hooks_onHeartbeatEpChannelAvailable = (remoteChannelUnknown, pluginId, heartbeatInterval) => {
2026
2029
  let resource = remoteChannelUnknown.resources[0];
@@ -2036,9 +2039,6 @@ function Make($star) {
2036
2039
  let hooks_scheduler = {
2037
2040
  contents: undefined
2038
2041
  };
2039
- let hooks_schedulerRoleUrn = {
2040
- contents: Pulumi.output("")
2041
- };
2042
2042
  let hooks_deployTarget = {
2043
2043
  contents: "Domain"
2044
2044
  };
@@ -2054,7 +2054,7 @@ function Make($star) {
2054
2054
  onHeartbeatEpChannelAvailable: hooks_onHeartbeatEpChannelAvailable,
2055
2055
  adminExtensionPoints: hooks_adminExtensionPoints,
2056
2056
  scheduler: hooks_scheduler,
2057
- schedulerRoleUrn: hooks_schedulerRoleUrn,
2057
+ schedulerRoleUrn: schedulerRoleUrnRef,
2058
2058
  api: hooksApiRef,
2059
2059
  apiRole: hooksApiRoleRef,
2060
2060
  adminApi: hooksAdminApiRef,
@@ -2129,7 +2129,7 @@ function Make($star) {
2129
2129
  let makeScheduler = () => {
2130
2130
  let component = Scheduler$ReventlessAws.make(undefined);
2131
2131
  let outputs = Component$ReventlessCore.outputs(component);
2132
- hooks_schedulerRoleUrn.contents = outputs.resource.urn;
2132
+ schedulerRoleUrnRef.contents = outputs.resource.urn;
2133
2133
  return Component$ReventlessCore.operations(component);
2134
2134
  };
2135
2135
  let provisionPgChangeFeedRelay = () => {
@@ -2283,7 +2283,7 @@ function Make($star) {
2283
2283
  } else {
2284
2284
  pluginReadModelTableName = undefined;
2285
2285
  }
2286
- PluginExtensionPointRuntime_Builder$ReventlessAws.registerPluginExtensionPoint(pluginReadModelTableName, hooks_schedulerRoleUrn.contents, undefined);
2286
+ PluginExtensionPointRuntime_Builder$ReventlessAws.registerPluginExtensionPoint(pluginReadModelTableName, schedulerRoleUrnRef.contents, undefined);
2287
2287
  let pluginEpEventTopicArn = Output$Pulumi.flatMap(admin.extensionPointsOutputs, eps => {
2288
2288
  let ep = eps.find(ep => ep.name === PluginExtensionPointSpec$ReventlessInfra.name);
2289
2289
  if (ep !== undefined) {
@@ -2299,7 +2299,7 @@ function Make($star) {
2299
2299
  return Pulumi.output("NOT_AVAILABLE");
2300
2300
  }
2301
2301
  });
2302
- PluginRuntime_Builder$ReventlessAws.registerConfig(pluginEpEventTopicArn, pluginReadModelTableName, hooks_schedulerRoleUrn.contents, undefined, undefined, domainApiId, false, undefined);
2302
+ PluginRuntime_Builder$ReventlessAws.registerConfig(pluginEpEventTopicArn, pluginReadModelTableName, schedulerRoleUrnRef.contents, undefined, undefined, domainApiId, false, undefined);
2303
2303
  let offloadProtection = Util_StoreLayout$ReventlessAws.protectionOfDeployedStack();
2304
2304
  let offloadBucket = new (Aws.s3.Bucket)("reventless-offload", {
2305
2305
  forceDestroy: offloadProtection === "Unprotected",
@@ -70,7 +70,8 @@ async function buildAllHandlers() {
70
70
  : Ops.makeAutomationRegisteredHandler(
71
71
  entry,
72
72
  specModule.name,
73
- automationSliceCallbackMake(specModule)(bodyModule)
73
+ automationSliceCallbackMake(specModule)(bodyModule),
74
+ specModule.schedule
74
75
  );
75
76
  // A slice can listen on several streams — its plugin's DCB log and any
76
77
  // Aggregate EventTopic it named. Register the one handler under each, since
@@ -100,11 +101,14 @@ const routed = StreamOps.makeRoutedHandler(
100
101
  builtPromise.then((b) => b.handlers),
101
102
  );
102
103
 
103
- // The scheduled sweep arrives as the EventBridge rule's constant Input, which
104
- // has no `records` — so this branch is the one place that must probe an untyped
105
- // Lambda payload, which is what this shell exists for. Everything it dispatches
106
- // to is type-checked in AutomationSliceEntryPoint_Ops.
107
- export const handler = async (event, context) =>
108
- event?.reventlessSweep
109
- ? Ops.runSweeps((await builtPromise).sweeps)
110
- : routed(event, context);
104
+ // Three invocations carry no `records`: the sweep (the EventBridge rule's constant
105
+ // Input), a schedule's tick (the Scheduler's input), and the deploy's reconcile of
106
+ // the declared schedules. Telling them apart means probing an untyped Lambda
107
+ // payload, which is what this shell exists for. Everything they dispatch to is
108
+ // type-checked in AutomationSliceEntryPoint_Ops.
109
+ export const handler = async (event, context) => {
110
+ if (event?.reventlessSweep) return Ops.runSweeps((await builtPromise).sweeps);
111
+ if (event?.reventlessTick) return Ops.runTick((await builtPromise).sweeps, event.reventlessTick);
112
+ if (event?.reventlessReconcile) return Ops.reconcileSchedules((await builtPromise).sweeps);
113
+ return routed(event, context);
114
+ };
@@ -32,6 +32,8 @@ type handlerEntry = {
32
32
  commandQueueIsFifo: option<bool>,
33
33
  sourceUrn: string,
34
34
  context: option<Reventless.AutomationSlice.context>,
35
+ /** The slice's schedules store; absent for a slice that maps no `Clock`. */
36
+ schedulesTableName: option<string>,
35
37
  }
36
38
 
37
39
  let strOf = (obj: dict<JSON.t>, key: string): option<string> =>
@@ -59,6 +61,7 @@ let decodeEntry = (json: JSON.t): option<handlerEntry> =>
59
61
  commandQueueIsFifo: h->Dict.get("commandQueueIsFifo")->Option.flatMap(JSON.Decode.bool),
60
62
  sourceUrn: h->strOf("sourceUrn")->Option.getOr(""),
61
63
  context: h->Dict.get("context")->Option.flatMap(decodeContext),
64
+ schedulesTableName: h->strOf("schedulesTableName"),
62
65
  })
63
66
 
64
67
  // A compact entry names its slice and carries only what the deploy resolved;
@@ -96,6 +99,7 @@ let decodeCompactEntry = (
96
99
  // urns stay in the config for the multi-source registration to use.
97
100
  sourceUrn: urns->Array.get(0)->Option.getOr(""),
98
101
  context: m->Dict.get("context")->Option.flatMap(decodeContext),
102
+ schedulesTableName: h->strOf("s"),
99
103
  }
100
104
  }
101
105
 
@@ -286,6 +290,94 @@ let makeLoadTodoItems = (
286
290
  }
287
291
  }
288
292
 
293
+ // ── An automation's schedules ───────────────────────────────────────────────
294
+ // The rows are the truth: they are written before the platform is told, and a
295
+ // cold container reads them all once, as it does the to-do list. Unlike to-do
296
+ // rows, a schedule row can go, so the sync deletes what the dict no longer holds.
297
+
298
+ let scheduleTable = (tableName): Util_DynamoDb_Runtime.resolvedTable => {
299
+ id: "",
300
+ name: tableName,
301
+ arn: "",
302
+ hashKey: "id",
303
+ }
304
+
305
+ let makeScheduleStore = (
306
+ ~tableName,
307
+ ~rows: dict<ReventlessCore.AutomationSlice_Schedules.row>,
308
+ ~comp,
309
+ ) => {
310
+ let table = scheduleTable(tableName)
311
+ let save = QueryDbStorage_DynamoDb_Runtime.save(table)
312
+ let delete = QueryDbStorage_DynamoDb_Runtime.delete(table)
313
+ let stored: ref<array<string>> = ref([])
314
+ let loaded = ref(false)
315
+ let load = async () =>
316
+ if !loaded.contents {
317
+ loaded := true
318
+ let params: AwsSdk.DynamoDb.DocumentClient.ScanCommand.input = {
319
+ tableName,
320
+ consistentRead: true,
321
+ }
322
+ let _ = await Util_DynamoDb_Runtime.scanStream(params)
323
+ ->Stream.runCollect
324
+ ->Effect.map(items =>
325
+ items->Array.forEach(item => {
326
+ let json = item->JSON.stringifyAny->Option.getOr("")->JSON.parseOrThrow
327
+ switch json->Reventless.Util_Sury.fromJson(
328
+ ReventlessCore.AutomationSlice_Schedules.rowSchema,
329
+ ) {
330
+ | row if rows->Dict.get(row.key)->Option.isNone => rows->Dict.set(row.key, row)
331
+ | _ => ()
332
+ | exception _ => ()
333
+ }
334
+ })
335
+ )
336
+ ->Effect.catchAll(err =>
337
+ ReventlessCore.EffectLogger.logError(
338
+ ~comp,
339
+ `restore: couldn't read the schedules from ${tableName}: ${DynamoDb_Error.message(err)}`,
340
+ )
341
+ )
342
+ ->Effect.runPromise
343
+ stored := rows->Dict.keysToArray
344
+ }
345
+ let sync = async () => {
346
+ let keys = rows->Dict.keysToArray
347
+ let _ = await rows
348
+ ->Dict.toArray
349
+ ->Array.map(((key, row)) =>
350
+ save(
351
+ key,
352
+ row->Reventless.Util_Sury.toJson(ReventlessCore.AutomationSlice_Schedules.rowSchema),
353
+ ReventlessCore.QueryDb.Overwrite,
354
+ None,
355
+ )
356
+ )
357
+ ->Promise.all
358
+ let _ = await stored.contents
359
+ ->Array.filter(key => !(keys->Array.includes(key)))
360
+ ->Array.map(key => delete(key, None))
361
+ ->Promise.all
362
+ stored := keys
363
+ }
364
+ (load, sync)
365
+ }
366
+
367
+ /** This Lambda's own ARN, which the slice's schedules invoke. */
368
+ let ownArn = () => {
369
+ let env = key => NodeProcess.env->Dict.get(key)->Option.getOr("")
370
+ let account = env("SCHEDULER_ROLE_ARN")->String.split(":")->Array.get(4)->Option.getOr("")
371
+ `arn:aws:lambda:${env("AWS_REGION")}:${account}:function:${env("AWS_LAMBDA_FUNCTION_NAME")}`
372
+ }
373
+
374
+ let platformSchedulesOf = (~slice) =>
375
+ ScheduledPublisher_EventBridgeScheduler_Runtime.platformSchedules(
376
+ ~roleArn=NodeProcess.env->Dict.get("SCHEDULER_ROLE_ARN")->Option.getOr(""),
377
+ ~lambdaArn=ownArn(),
378
+ ~slice,
379
+ )
380
+
289
381
  // ── Platform capabilities ───────────────────────────────────────────────────
290
382
 
291
383
  /**
@@ -361,6 +453,32 @@ let capabilities = (): Reventless.Capabilities.t => {
361
453
  type automationCallback = ReventlessCore.AutomationSlice_Callback.runtime
362
454
  type outboundCallback<'event> = ReventlessCore.OutboundTranslationSlice_Callback.runtime<'event>
363
455
 
456
+ /** A scheduled slice's schedule rows, as the Lambda keeps them: load once, sync,
457
+ and the platform that follows them. */
458
+ type schedulesStep = {
459
+ load: unit => promise<unit>,
460
+ sync: unit => promise<unit>,
461
+ platform: ReventlessInfra.AutomationSlice.platformSchedules,
462
+ }
463
+
464
+ let loadSchedules = async step =>
465
+ switch step {
466
+ | Some({load}) => await load()
467
+ | None => ()
468
+ }
469
+
470
+ // The rows are stored first, then the platform follows them.
471
+ let followSchedules = async (step, callback: automationCallback) =>
472
+ switch step {
473
+ | Some({sync, platform}) =>
474
+ let changes = callback.takeScheduleChanges()
475
+ if changes->Array.length > 0 {
476
+ await sync()
477
+ await ReventlessCore.AutomationSlice_Schedules.tell(platform, changes)
478
+ }
479
+ | None => ()
480
+ }
481
+
364
482
  // Automation: phase 1 takes the RAW envelope JSONs — the callback routes by
365
483
  // `meta.service` and unwraps `{id, meta, event}` itself — plus the context.
366
484
  let makeAutomationJsonEventsHandler = (
@@ -369,6 +487,7 @@ let makeAutomationJsonEventsHandler = (
369
487
  ~publishJsons: ReventlessCore.CommandTopic.publishJsons,
370
488
  ~syncTodoItems: unit => promise<unit>,
371
489
  ~loadTodoItems: unit => promise<unit>,
490
+ ~schedules: unit => option<schedulesStep>=() => None,
372
491
  ): ReventlessCore.EventCollector.jsonEventsHandler =>
373
492
  stream =>
374
493
  stream
@@ -376,8 +495,10 @@ let makeAutomationJsonEventsHandler = (
376
495
  ->Effect.flatMap(jsons =>
377
496
  Effect.promise(async () => {
378
497
  await loadTodoItems()
498
+ await loadSchedules(schedules())
379
499
  callback.phase1(jsons, context)
380
500
  await syncTodoItems()
501
+ await followSchedules(schedules(), callback)
381
502
  await callback.phase2(publishJsons)
382
503
  await syncTodoItems()
383
504
  })
@@ -447,12 +568,19 @@ type builtSlice = {
447
568
  registered: StreamRoutedEntryPoint_Ops.registeredHandler,
448
569
  sweep: unit => promise<unit>,
449
570
  comp: string,
571
+ name: string,
572
+ /** Delivers `Tick({key, at})`; `None` for an outbound slice, which maps no `Clock`. */
573
+ tick: option<(~key: string, ~at: float) => promise<unit>>,
574
+ /** Brings the slice's schedules in line with its spec and its rows, at a deploy;
575
+ `None` for an outbound slice. */
576
+ reconcile: option<unit => promise<unit>>,
450
577
  }
451
578
 
452
579
  let makeAutomationRegisteredHandler = (
453
580
  entry: handlerEntry,
454
581
  ~sliceName: string,
455
582
  ~callback: automationCallback,
583
+ ~schedule: option<Reventless.Schedule.time>,
456
584
  ): builtSlice => {
457
585
  let comp = `AutomationSlice(${sliceName})`
458
586
  let publishJsons = makePublishJsons(
@@ -470,15 +598,29 @@ let makeAutomationRegisteredHandler = (
470
598
  ~rowSchema=ReventlessCore.AutomationSlice_Callback.todoRowSchema,
471
599
  ~comp,
472
600
  )
601
+ let context = entry.context->Option.getOr(defaultContext(sliceName))
602
+ // Built on first use: the platform reads the environment, which a test sets late.
603
+ let step = ref(None)
604
+ let schedules = () =>
605
+ switch (step.contents, entry.schedulesTableName) {
606
+ | (Some(s), _) => Some(s)
607
+ | (None, Some(tableName)) =>
608
+ let (load, sync) = makeScheduleStore(~tableName, ~rows=callback.schedules, ~comp)
609
+ let s = {load, sync, platform: platformSchedulesOf(~slice=sliceName)}
610
+ step := Some(s)
611
+ Some(s)
612
+ | (None, None) => None
613
+ }
473
614
  {
474
615
  registered: {
475
616
  handler: StreamRoutedEntryPoint_Ops.toStreamHandler(
476
617
  makeAutomationJsonEventsHandler(
477
- ~context=entry.context->Option.getOr(defaultContext(sliceName)),
618
+ ~context,
478
619
  ~callback,
479
620
  ~publishJsons,
480
621
  ~syncTodoItems,
481
622
  ~loadTodoItems,
623
+ ~schedules,
482
624
  ),
483
625
  ),
484
626
  comp,
@@ -489,6 +631,44 @@ let makeAutomationRegisteredHandler = (
489
631
  await syncTodoItems()
490
632
  },
491
633
  comp,
634
+ name: sliceName,
635
+ // The same steps as an event batch, with the tick as its one event.
636
+ tick: Some(
637
+ async (~key, ~at) => {
638
+ await loadTodoItems()
639
+ await loadSchedules(schedules())
640
+ callback.phase1([ReventlessCore.AutomationSlice_Callback.tickEvent(~key, ~at)], context)
641
+ await syncTodoItems()
642
+ // A once-only schedule's row goes when it fires.
643
+ switch schedules() {
644
+ | Some({sync}) => await sync()
645
+ | None => ()
646
+ }
647
+ await callback.phase2(publishJsons)
648
+ await syncTodoItems()
649
+ },
650
+ ),
651
+ reconcile: Some(
652
+ async () =>
653
+ switch schedules() {
654
+ | Some({load, sync, platform}) =>
655
+ await load()
656
+ await ReventlessCore.AutomationSlice_Schedules.reconcile(
657
+ callback.schedules,
658
+ platform,
659
+ ~declaredTime=schedule,
660
+ ~now=Date.now(),
661
+ )
662
+ await sync()
663
+ // No Clock mapping, so no schedule of the slice may stay on the platform.
664
+ | None =>
665
+ let platform = platformSchedulesOf(~slice=sliceName)
666
+ let names = await platform.names()
667
+ for i in 0 to names->Array.length - 1 {
668
+ await platform.removeName(names->Array.getUnsafe(i))
669
+ }
670
+ },
671
+ ),
492
672
  }
493
673
  }
494
674
 
@@ -533,6 +713,9 @@ let makeOutboundRegisteredHandler = (
533
713
  await syncTodoItems()
534
714
  },
535
715
  comp,
716
+ name: sliceName,
717
+ tick: None,
718
+ reconcile: None,
536
719
  }
537
720
  }
538
721
 
@@ -557,3 +740,46 @@ let runSweeps = async (slices: array<builtSlice>) => {
557
740
  }
558
741
  }
559
742
  }
743
+
744
+ // ── Schedules ───────────────────────────────────────────────────────────────
745
+
746
+ /** A Scheduler invocation: which slice's schedule fired, its key, and its scheduled time. */
747
+ type tickInvocation = {slice: string, key: string, at: string}
748
+
749
+ /**
750
+ Deliver a schedule's firing to its slice. `at` is the Scheduler's placeholder,
751
+ filled in as `2026-10-16T06:00:00Z`. A tick for a slice this Lambda does not host,
752
+ or one that maps no `Clock`, throws, so the Scheduler retries and the error is logged.
753
+ */
754
+ let runTick = async (slices: array<builtSlice>, invocation: tickInvocation) =>
755
+ switch slices->Array.find(s => s.name == invocation.slice)->Option.flatMap(s => s.tick) {
756
+ | Some(tick) =>
757
+ let at = invocation.at->Date.fromString->Date.getTime
758
+ if at->Float.isNaN {
759
+ JsError.throwWithMessage(`tick for ${invocation.slice}: "${invocation.at}" is not a time`)
760
+ }
761
+ await tick(~key=invocation.key, ~at)
762
+ | None =>
763
+ JsError.throwWithMessage(`tick for ${invocation.slice}: no automation of that name here`)
764
+ }
765
+
766
+ /**
767
+ Bring every automation's schedules in line, at a deploy: its declared time into its
768
+ rows, and the platform's schedules to its rows. Run by the deploy, so a failure fails
769
+ it; unlike a sweep, one slice's failure stops the rest.
770
+ */
771
+ let reconcileSchedules = async (slices: array<builtSlice>) => {
772
+ if NodeProcess.env->Dict.get("SCHEDULER_ROLE_ARN")->Option.getOr("") == "" {
773
+ JsError.throwWithMessage(`reconcile: SCHEDULER_ROLE_ARN is not set`)
774
+ }
775
+ let reconciles = slices->Array.filterMap(s => s.reconcile)
776
+ for i in 0 to reconciles->Array.length - 1 {
777
+ await (reconciles->Array.getUnsafe(i))()
778
+ }
779
+ ReventlessCore.EffectLogger.logInfo(
780
+ ~comp="AutomationSliceRuntime",
781
+ `reconcile: the schedules of ${reconciles
782
+ ->Array.length
783
+ ->Int.toString} automation(s) are in line`,
784
+ )->Effect.runSync
785
+ }