@reventlessdev/reventless-aws 3.0.0-alpha.290 → 3.0.0-alpha.292

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.
@@ -38,7 +38,7 @@ export function response(ctx) {
38
38
  }
39
39
  `;
40
40
 
41
- function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opts) {
41
+ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bakedManifest, opts) {
42
42
  let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
43
43
  let name = "PlatformUIDefinitions";
44
44
  let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "Lambda", Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name + "Lambda", "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
@@ -46,6 +46,7 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
46
46
  pluginReadModelTableName,
47
47
  offloadBucketName
48
48
  ]).apply(param => {
49
+ let offloadBucket = param[1];
49
50
  new (Aws.iam.RolePolicy)(name + "LambdaPolicy", {
50
51
  policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "LambdaPolicy", [
51
52
  {
@@ -64,12 +65,30 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
64
65
  Sid: "AllowOffloadGet",
65
66
  Effect: "Allow",
66
67
  Action: ["s3:GetObject"],
67
- Resource: "arn:aws:s3:::" + param[1] + "/*"
68
+ Resource: "arn:aws:s3:::" + offloadBucket + "/*"
69
+ },
70
+ {
71
+ Sid: "AllowOffloadList",
72
+ Effect: "Allow",
73
+ Action: ["s3:ListBucket"],
74
+ Resource: "arn:aws:s3:::" + offloadBucket
68
75
  }
69
76
  ])),
70
77
  role: lambdaRole.id
71
78
  }, opts$1);
72
79
  });
80
+ let bakeSelectionsJson = bakedManifest !== undefined ? JSON.stringify(bakedManifest.components.map(sel => {
81
+ let entry = Object.fromEntries([[
82
+ "plugin",
83
+ sel.plugin
84
+ ]]);
85
+ let strings = (key, value) => Stdlib_Option.forEach(value, names => {
86
+ entry[key] = names.map(prim => prim);
87
+ });
88
+ strings("views", sel.views);
89
+ strings("commands", sel.commands);
90
+ return entry;
91
+ })) : "";
73
92
  let adminEntryJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(Platform_Admin_Structure$ReventlessCore.pluginId, Platform_Admin_Structure$ReventlessCore.structure));
74
93
  let packageDirs = Object.fromEntries([[
75
94
  "@reventlessdev/reventless-aws",
@@ -104,6 +123,10 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
104
123
  "OFFLOAD_BUCKET",
105
124
  offloadBucketName
106
125
  ],
126
+ [
127
+ "BAKE_SELECTIONS",
128
+ bakeSelectionsJson
129
+ ],
107
130
  [
108
131
  "NODE_OPTIONS",
109
132
  Util_Bundle$ReventlessAws.esmLoaderNodeOptions
@@ -143,6 +166,10 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
143
166
  }, opts$1);
144
167
  AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "Resolver", api, dataSource.name, "Query", "Platform_ComponentDefinitions", resolverCode, opts$1);
145
168
  schemaReady.apply(() => AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "StructuresResolver", api, dataSource.name, "Query", "Platform_PluginStructures", completeResolverCode, opts$1));
169
+ return {
170
+ roleId: lambdaRole.id,
171
+ functionName: lambda.name
172
+ };
146
173
  }
147
174
 
148
175
  export {
@@ -216,7 +216,18 @@ let resolveStructure = (
216
216
  ->Option.flatMap(JSON.Decode.string) {
217
217
  | None => Promise.resolve(item)
218
218
  | Some(key) =>
219
- fetch(key)->Promise.then(bytes => {
219
+ fetch(key)
220
+ // The bucket answers for the whole platform, so a failure here says which
221
+ // plugin's ref could not be read — otherwise the S3 error names the key
222
+ // and the bucket, and finding the row it came from is a table scan by hand.
223
+ ->Promise.catch(e => {
224
+ let plugin = item->str("name")->Option.getOr("<unnamed>")
225
+ JsError.throwWithMessage(
226
+ `offloaded structure for plugin ${plugin} is unreadable at ${key}: ` ++
227
+ e->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error"),
228
+ )
229
+ })
230
+ ->Promise.then(bytes => {
220
231
  let resolved = Dict.fromArray(item->Dict.toArray)
221
232
  resolved->Dict.set("structure", JSON.parseOrThrow(bytes))
222
233
  Promise.resolve(resolved)
@@ -236,14 +247,214 @@ let isComplete = (event: JSON.t): bool =>
236
247
  ->Option.flatMap(JSON.Decode.bool)
237
248
  ->Option.getOr(false)
238
249
 
250
+ // ── Bake mode ────────────────────────────────────────────────────────────────
251
+ // The third thing this function does with the same scan: write the curated
252
+ // manifest as a static object instead of answering a query with it.
253
+ //
254
+ // It belongs here rather than in a tool of its own for the reason the two GraphQL
255
+ // fields already share a data source — there is one place that decides what a
256
+ // deployed plugin's structure is, and the scan, the offload resolution and the
257
+ // version collapse are that decision. A separate reader would be a second copy of
258
+ // all three, free to drift about which version of a plugin is deployed.
259
+ //
260
+ // Invoked directly, after every plugin stack is up: the manifest describes the
261
+ // whole deployment, and no single stack's deploy is the moment that is settled.
262
+
263
+ // The persisted structure is served raw on the query paths — nothing decodes it —
264
+ // but curation filters the RECORD and re-encodes through the shared encoder,
265
+ // which is what makes an include-list naming everything produce the bytes the
266
+ // query returns. So the bake decodes, and heals first: healing fills the list
267
+ // fields an older persisted structure simply has no key for, and those are
268
+ // exactly the fields the schema requires.
269
+ //
270
+ // A structure that still fails to decode fails the bake, naming the plugin. The
271
+ // alternative — skipping it — ships a shop silently missing a section.
272
+ let structureOf = (item: dict<JSON.t>, ~name as _: string): option<(
273
+ string,
274
+ Reventless.Plugin.pluginStructure,
275
+ )> =>
276
+ switch (item->str("name"), item->Dict.get("structure")->Option.flatMap(JSON.Decode.object)) {
277
+ | (Some(pluginId), Some(structure)) =>
278
+ let healed = structure->healStructure->JSON.Encode.object
279
+ switch healed->S.parseJsonOrThrow(Reventless.Plugin.pluginStructureSchema) {
280
+ | decoded => Some((pluginId, decoded))
281
+ | exception _ =>
282
+ JsError.throwWithMessage(
283
+ `baked manifest: the structure persisted for "${pluginId}" cannot be decoded — ` ++
284
+ `it was written by a framework version this platform can no longer read. ` ++
285
+ `Redeploy that plugin, or drop it from the include-list.`,
286
+ )
287
+ }
288
+ | _ => None
289
+ }
290
+
291
+ let bakeSelection = (json: JSON.t): option<ReventlessCore.Platform_BakedManifest.selection> => {
292
+ let strings = (o, key) =>
293
+ o
294
+ ->Dict.get(key)
295
+ ->Option.flatMap(JSON.Decode.array)
296
+ ->Option.map(a => a->Array.filterMap(JSON.Decode.string))
297
+ json
298
+ ->JSON.Decode.object
299
+ ->Option.flatMap(o =>
300
+ o
301
+ ->Dict.get("plugin")
302
+ ->Option.flatMap(JSON.Decode.string)
303
+ ->Option.map(plugin => {
304
+ ReventlessCore.Platform_BakedManifest.plugin,
305
+ views: strings(o, "views"),
306
+ commands: strings(o, "commands"),
307
+ })
308
+ )
309
+ }
310
+
311
+ // The declaration travels as an env var because it is deploy input — stated once
312
+ // in the platform program, beside the bucket it is written to. The target travels
313
+ // in the invocation payload instead: the bucket only exists inside the host-UI
314
+ // half of the deploy, long after this function is built, and a caller that had to
315
+ // know the include-list as well would be free to bake something the deployment
316
+ // never declared.
317
+ let bakeSelections = (): array<ReventlessCore.Platform_BakedManifest.selection> =>
318
+ switch NodeProcess.env->Dict.get("BAKE_SELECTIONS") {
319
+ | None | Some("") => []
320
+ | Some(raw) =>
321
+ switch raw->JSON.parseOrThrow->JSON.Decode.array {
322
+ | Some(entries) => entries->Array.filterMap(bakeSelection)
323
+ | None => []
324
+ | exception _ =>
325
+ JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array")
326
+ }
327
+ }
328
+
329
+ type bakeTarget = {bucket: string, key: string}
330
+
331
+ let bakeTargetOf = (event: JSON.t): option<bakeTarget> =>
332
+ event
333
+ ->JSON.Decode.object
334
+ ->Option.flatMap(o =>
335
+ switch (
336
+ o->Dict.get("bake")->Option.flatMap(JSON.Decode.bool),
337
+ o->Dict.get("bucket")->Option.flatMap(JSON.Decode.string),
338
+ ) {
339
+ | (Some(true), Some(bucket)) =>
340
+ Some({
341
+ bucket,
342
+ key: o
343
+ ->Dict.get("key")
344
+ ->Option.flatMap(JSON.Decode.string)
345
+ ->Option.getOr(ReventlessCore.Platform_BakedManifest.defaultKey),
346
+ })
347
+ | _ => None
348
+ }
349
+ )
350
+
351
+ // ── Registration freshness ───────────────────────────────────────────────────
352
+ // The read model this bake scans is updated asynchronously: the plugin stack
353
+ // publishes a re-detect, the plugin answers with its definition, the projection
354
+ // lands. Invoked seconds after the last stack finished, the scan can still
355
+ // describe the deploy before it — and a manifest baked from that is wrong in the
356
+ // one way nothing downstream can detect, because it is a perfectly well-formed
357
+ // description of the wrong deployment.
358
+ //
359
+ // So the invocation carries the structure key each plugin stack just wrote. That
360
+ // is an equality check rather than an inference from timestamps, and it costs
361
+ // nothing for a plugin that was not redeployed: its key already matches. A caller
362
+ // that supplies no expectations bakes whatever is current — the query paths never
363
+ // send any, and a hand-run bake should not need to.
364
+ let bakeExpectations = (event: JSON.t): dict<string> =>
365
+ event
366
+ ->JSON.Decode.object
367
+ ->Option.flatMap(o => o->Dict.get("expect"))
368
+ ->Option.flatMap(JSON.Decode.object)
369
+ ->Option.mapOr(Dict.make(), o =>
370
+ o
371
+ ->Dict.toArray
372
+ ->Array.filterMap(((plugin, key)) => key->JSON.Decode.string->Option.map(k => (plugin, k)))
373
+ ->Dict.fromArray
374
+ )
375
+
376
+ // The offload key a scanned row carries. None for a structure held inline, which
377
+ // on a deployed platform means the row predates offloading — it cannot match an
378
+ // expectation, and saying so beats baking it.
379
+ let structureRefKey = (item: dict<JSON.t>): option<string> =>
380
+ item
381
+ ->Dict.get("structure")
382
+ ->Option.flatMap(JSON.Decode.object)
383
+ ->Option.flatMap(o => o->Dict.get(Reventless.Offload.sentinelKey))
384
+ ->Option.flatMap(JSON.Decode.object)
385
+ ->Option.flatMap(r => r->Dict.get("key"))
386
+ ->Option.flatMap(JSON.Decode.string)
387
+
388
+ // Compared against the collapsed latest version per plugin, the same view the
389
+ // bake itself takes — an older version's row lingering on the table is not the
390
+ // registration anyone is waiting for.
391
+ let pendingRegistrations = (items: array<dict<JSON.t>>, ~expect: dict<string>): array<string> => {
392
+ let current =
393
+ Platform_AdminScan_Ops.latestByName(
394
+ items,
395
+ ~nameVersionOf=item => item->str("name"),
396
+ ~toEntry=(item, ~name) => item->structureRefKey->Option.map(key => (name, key)),
397
+ )->Dict.fromArray
398
+ expect
399
+ ->Dict.toArray
400
+ ->Array.filterMap(((plugin, key)) => current->Dict.get(plugin) == Some(key) ? None : Some(plugin))
401
+ }
402
+
403
+ // Every failure mode here is the deployment's own mistake — a name matching no
404
+ // component, a structure too old to read, a bucket the function may not write —
405
+ // and every one of them produces the same symptom if swallowed: a shop that
406
+ // renders nothing, with no line anywhere saying why. So the bake throws, and the
407
+ // pipeline step that invoked it fails.
408
+ let runBake = async (
409
+ ~target: bakeTarget,
410
+ ~structures: array<(string, Reventless.Plugin.pluginStructure)>,
411
+ ): array<JSON.t> => {
412
+ let selections = bakeSelections()
413
+ if selections->Array.length == 0 {
414
+ JsError.throwWithMessage(
415
+ "baked manifest: BAKE_SELECTIONS is empty — this platform declares no bake, " ++
416
+ "so there is nothing to write and a shell pointed at the file would find none.",
417
+ )
418
+ }
419
+ switch ReventlessCore.Platform_BakedManifest.curate(~structures, ~selections) {
420
+ | Error(e) =>
421
+ JsError.throwWithMessage(ReventlessCore.Platform_BakedManifest.describe(e))
422
+ | Ok(manifest) =>
423
+ let body = JSON.stringify(manifest, ~space=2)
424
+ let _ = await AwsSdk.S3.PutObjectCommand.make({
425
+ bucket: target.bucket,
426
+ key: target.key,
427
+ body: AwsSdk.S3.PutObjectCommand.bodyFromString(body),
428
+ contentType: "application/json",
429
+ })->AwsSdk.S3.PutObjectCommand.send
430
+ [
431
+ Dict.fromArray([
432
+ ("baked", JSON.Encode.bool(true)),
433
+ ("bucket", JSON.Encode.string(target.bucket)),
434
+ ("key", JSON.Encode.string(target.key)),
435
+ ("plugins", JSON.Encode.int(selections->Array.length)),
436
+ ("bytes", JSON.Encode.int(body->String.length)),
437
+ ])->JSON.Encode.object,
438
+ ]
439
+ }
440
+ }
441
+
239
442
  let handler = async (event: JSON.t): array<JSON.t> => {
240
443
  let complete = isComplete(event)
444
+ let bakeTarget = bakeTargetOf(event)
241
445
  let toEntry = (item, ~name) => toEntryWith(~filter=!complete, item, ~name)
242
446
  let admin = adminEntry->Option.mapOr([], e => [e])
243
447
  switch NodeProcess.env->Dict.get("PLUGIN_RM_TABLE") {
244
448
  | None | Some("") =>
245
449
  Console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set")
246
- admin
450
+ switch bakeTarget {
451
+ | Some(_) =>
452
+ JsError.throwWithMessage(
453
+ "baked manifest: PLUGIN_RM_TABLE env var not set — the bake would write an " ++
454
+ "empty shop rather than fail.",
455
+ )
456
+ | None => admin
457
+ }
247
458
  | Some(table) =>
248
459
  let rawItems = await Platform_AdminScan_Ops.scanAll(
249
460
  ~tableName=table,
@@ -256,9 +467,40 @@ let handler = async (event: JSON.t): array<JSON.t> => {
256
467
  let fetch = Reventless.Offload.cachedFetch(key =>
257
468
  AwsSdk.S3.GetObjectCommand.getString(~bucket, ~key)
258
469
  )
259
- let items = await Promise.all(rawItems->Array.map(item => resolveStructure(fetch, item)))
260
- let userEntries =
261
- Platform_AdminScan_Ops.latestByName(items, ~nameVersionOf=item => item->str("name"), ~toEntry)
262
- Array.concat(admin, userEntries)
470
+ let resolveAll = () => Promise.all(rawItems->Array.map(item => resolveStructure(fetch, item)))
471
+ switch bakeTarget {
472
+ | Some(target) =>
473
+ // Checked on the raw rows: the refs are what the deploy can predict, and a
474
+ // row that is behind should not have its structure fetched at all.
475
+ switch pendingRegistrations(rawItems, ~expect=bakeExpectations(event)) {
476
+ | [] =>
477
+ // The built-in admin entry is deliberately absent: it never enters the
478
+ // Plugin read model, and the in-memory bake curates the composed plugins
479
+ // only. A deployment naming it gets `UnknownPlugin`, on both platforms.
480
+ let structures = Platform_AdminScan_Ops.latestByName(
481
+ await resolveAll(),
482
+ ~nameVersionOf=item => item->str("name"),
483
+ ~toEntry=structureOf,
484
+ )
485
+ await runBake(~target, ~structures)
486
+ | pending =>
487
+ // Not an error — the deploy just has not finished arriving. Reported so the
488
+ // caller can invoke again rather than bake the previous deployment.
489
+ [
490
+ Dict.fromArray([
491
+ ("baked", JSON.Encode.bool(false)),
492
+ ("pending", pending->Array.map(JSON.Encode.string)->JSON.Encode.array),
493
+ ])->JSON.Encode.object,
494
+ ]
495
+ }
496
+ | None =>
497
+ let userEntries =
498
+ Platform_AdminScan_Ops.latestByName(
499
+ await resolveAll(),
500
+ ~nameVersionOf=item => item->str("name"),
501
+ ~toEntry,
502
+ )
503
+ Array.concat(admin, userEntries)
504
+ }
263
505
  }
264
506
  }
@@ -1,12 +1,21 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as S from "sury/src/S.res.mjs";
3
4
  import * as Nodefs from "node:fs";
4
5
  import * as S3$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/S3.res.mjs";
5
6
  import * as Nodepath from "node:path";
6
7
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
8
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
9
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
7
10
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
11
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
12
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
13
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
14
+ import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
15
+ import * as ClientS3 from "@aws-sdk/client-s3";
8
16
  import * as Offload$Reventless from "@reventlessdev/reventless-spec/src/semantic/Offload.res.mjs";
9
17
  import * as Platform_AdminScan_Ops$ReventlessAws from "./Platform_AdminScan_Ops.res.mjs";
18
+ import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
10
19
 
11
20
  function str(item, key) {
12
21
  return Stdlib_Option.flatMap(item[key], Stdlib_JSON.Decode.string);
@@ -210,7 +219,10 @@ function resolveStructure(fetch, item) {
210
219
  }
211
220
  let key = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(refJson), r => r["key"]), Stdlib_JSON.Decode.string);
212
221
  if (key !== undefined) {
213
- return fetch(key).then(bytes => {
222
+ return Stdlib_Promise.$$catch(fetch(key), e => {
223
+ let plugin = Stdlib_Option.getOr(str(item, "name"), "<unnamed>");
224
+ return Stdlib_JsError.throwWithMessage(`offloaded structure for plugin ` + plugin + ` is unreadable at ` + key + `: ` + Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(e), Stdlib_JsExn.message), "unknown error"));
225
+ }).then(bytes => {
214
226
  let resolved = Object.fromEntries(Object.entries(item));
215
227
  resolved["structure"] = JSON.parse(bytes);
216
228
  return Promise.resolve(resolved);
@@ -224,16 +236,147 @@ function isComplete(event) {
224
236
  return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["complete"]), Stdlib_JSON.Decode.bool), false);
225
237
  }
226
238
 
239
+ function structureOf(item, param) {
240
+ let match = str(item, "name");
241
+ let match$1 = Stdlib_Option.flatMap(item["structure"], Stdlib_JSON.Decode.object);
242
+ if (match === undefined) {
243
+ return;
244
+ }
245
+ if (match$1 === undefined) {
246
+ return;
247
+ }
248
+ let healed = healStructure(match$1);
249
+ let decoded;
250
+ try {
251
+ decoded = S.parseJsonOrThrow(healed, Plugin$Reventless.pluginStructureSchema);
252
+ } catch (exn) {
253
+ return Stdlib_JsError.throwWithMessage(`baked manifest: the structure persisted for "` + match + `" cannot be decoded — it was written by a framework version this platform can no longer read. Redeploy that plugin, or drop it from the include-list.`);
254
+ }
255
+ return [
256
+ match,
257
+ decoded
258
+ ];
259
+ }
260
+
261
+ function bakeSelection(json) {
262
+ let strings = (o, key) => Stdlib_Option.map(Stdlib_Option.flatMap(o[key], Stdlib_JSON.Decode.array), a => Stdlib_Array.filterMap(a, Stdlib_JSON.Decode.string));
263
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => Stdlib_Option.map(Stdlib_Option.flatMap(o["plugin"], Stdlib_JSON.Decode.string), plugin => ({
264
+ plugin: plugin,
265
+ views: strings(o, "views"),
266
+ commands: strings(o, "commands")
267
+ })));
268
+ }
269
+
270
+ function bakeSelections() {
271
+ let raw = process.env["BAKE_SELECTIONS"];
272
+ if (raw === undefined) {
273
+ return [];
274
+ }
275
+ if (raw === "") {
276
+ return [];
277
+ }
278
+ let entries;
279
+ try {
280
+ entries = Stdlib_JSON.Decode.array(JSON.parse(raw));
281
+ } catch (exn) {
282
+ return Stdlib_JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array");
283
+ }
284
+ if (entries !== undefined) {
285
+ return Stdlib_Array.filterMap(entries, bakeSelection);
286
+ } else {
287
+ return [];
288
+ }
289
+ }
290
+
291
+ function bakeTargetOf(event) {
292
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => {
293
+ let match = Stdlib_Option.flatMap(o["bake"], Stdlib_JSON.Decode.bool);
294
+ let match$1 = Stdlib_Option.flatMap(o["bucket"], Stdlib_JSON.Decode.string);
295
+ if (match !== undefined && match && match$1 !== undefined) {
296
+ return {
297
+ bucket: match$1,
298
+ key: Stdlib_Option.getOr(Stdlib_Option.flatMap(o["key"], Stdlib_JSON.Decode.string), Platform_BakedManifest$ReventlessCore.defaultKey)
299
+ };
300
+ }
301
+ });
302
+ }
303
+
304
+ function bakeExpectations(event) {
305
+ return Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["expect"]), Stdlib_JSON.Decode.object), {}, o => Object.fromEntries(Stdlib_Array.filterMap(Object.entries(o), param => {
306
+ let plugin = param[0];
307
+ return Stdlib_Option.map(Stdlib_JSON.Decode.string(param[1]), k => [
308
+ plugin,
309
+ k
310
+ ]);
311
+ })));
312
+ }
313
+
314
+ function structureRefKey(item) {
315
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(item["structure"], Stdlib_JSON.Decode.object), o => o[Offload$Reventless.sentinelKey]), Stdlib_JSON.Decode.object), r => r["key"]), Stdlib_JSON.Decode.string);
316
+ }
317
+
318
+ function pendingRegistrations(items, expect) {
319
+ let current = Object.fromEntries(Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), (item, name) => Stdlib_Option.map(structureRefKey(item), key => [
320
+ name,
321
+ key
322
+ ])));
323
+ return Stdlib_Array.filterMap(Object.entries(expect), param => {
324
+ let plugin = param[0];
325
+ if (Primitive_object.equal(current[plugin], param[1])) {
326
+ return;
327
+ } else {
328
+ return plugin;
329
+ }
330
+ });
331
+ }
332
+
333
+ async function runBake(target, structures) {
334
+ let selections = bakeSelections();
335
+ if (selections.length === 0) {
336
+ Stdlib_JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is empty — this platform declares no bake, so there is nothing to write and a shell pointed at the file would find none.");
337
+ }
338
+ let e = Platform_BakedManifest$ReventlessCore.curate(structures, selections);
339
+ if (e.TAG !== "Ok") {
340
+ return Stdlib_JsError.throwWithMessage(Platform_BakedManifest$ReventlessCore.describe(e._0));
341
+ }
342
+ let body = JSON.stringify(e._0, undefined, 2);
343
+ await S3$AwsSdk.PutObjectCommand.send(new ClientS3.PutObjectCommand({
344
+ Bucket: target.bucket,
345
+ Key: target.key,
346
+ Body: body,
347
+ ContentType: "application/json"
348
+ }));
349
+ return [Object.fromEntries([
350
+ [
351
+ "baked",
352
+ true
353
+ ],
354
+ [
355
+ "bucket",
356
+ target.bucket
357
+ ],
358
+ [
359
+ "key",
360
+ target.key
361
+ ],
362
+ [
363
+ "plugins",
364
+ selections.length
365
+ ],
366
+ [
367
+ "bytes",
368
+ body.length
369
+ ]
370
+ ])];
371
+ }
372
+
227
373
  async function handler(event) {
228
374
  let complete = isComplete(event);
375
+ let bakeTarget = bakeTargetOf(event);
229
376
  let toEntry = (item, name) => toEntryWith(!complete, item, name);
230
377
  let admin = Stdlib_Option.mapOr(adminEntry, [], e => [e]);
231
378
  let table = process.env["PLUGIN_RM_TABLE"];
232
- if (table !== undefined) {
233
- if (table === "") {
234
- console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set");
235
- return admin;
236
- }
379
+ if (table !== undefined && table !== "") {
237
380
  let rawItems = await Platform_AdminScan_Ops$ReventlessAws.scanAll(table, "contains(#status, :connected)", Object.fromEntries([[
238
381
  "#status",
239
382
  "status"
@@ -243,12 +386,33 @@ async function handler(event) {
243
386
  ]]));
244
387
  let bucket = Stdlib_Option.getOr(process.env["OFFLOAD_BUCKET"], "");
245
388
  let fetch = Offload$Reventless.cachedFetch(key => S3$AwsSdk.GetObjectCommand.getString(bucket, key));
246
- let items = await Promise.all(rawItems.map(item => resolveStructure(fetch, item)));
247
- let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), toEntry);
389
+ let resolveAll = () => Promise.all(rawItems.map(item => resolveStructure(fetch, item)));
390
+ if (bakeTarget !== undefined) {
391
+ let pending = pendingRegistrations(rawItems, bakeExpectations(event));
392
+ if (pending.length !== 0) {
393
+ return [Object.fromEntries([
394
+ [
395
+ "baked",
396
+ false
397
+ ],
398
+ [
399
+ "pending",
400
+ pending.map(prim => prim)
401
+ ]
402
+ ])];
403
+ }
404
+ let structures = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), structureOf);
405
+ return await runBake(bakeTarget, structures);
406
+ }
407
+ let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), toEntry);
248
408
  return admin.concat(userEntries);
249
409
  }
250
410
  console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set");
251
- return admin;
411
+ if (bakeTarget !== undefined) {
412
+ return Stdlib_JsError.throwWithMessage("baked manifest: PLUGIN_RM_TABLE env var not set — the bake would write an empty shop rather than fail.");
413
+ } else {
414
+ return admin;
415
+ }
252
416
  }
253
417
 
254
418
  export {
@@ -266,6 +430,14 @@ export {
266
430
  adminEntry,
267
431
  resolveStructure,
268
432
  isComplete,
433
+ structureOf,
434
+ bakeSelection,
435
+ bakeSelections,
436
+ bakeTargetOf,
437
+ bakeExpectations,
438
+ structureRefKey,
439
+ pendingRegistrations,
440
+ runBake,
269
441
  handler,
270
442
  }
271
443
  /* adminEntry Not a pure module */
@@ -186,6 +186,20 @@ let heartbeatConfigRef: ref<heartbeatConfig> = ref({
186
186
  let registerHeartbeatConfig = (~pluginId, ~heartbeatTimeout, ~epQueueUrl=?, ()) =>
187
187
  heartbeatConfigRef := {pluginId, heartbeatTimeout, epQueueUrl}
188
188
 
189
+ // Resolves once this plugin's EventCollector Lambda exists at its new code.
190
+ // `Platform.deployPlugin` gates the deploy-time re-detect on it: the re-detect
191
+ // only asks the platform to re-run the connect handshake, and this is the Lambda
192
+ // that ANSWERS it, with the `pluginDefinition` its deployed code carries. Fired
193
+ // before this Lambda is updated, the plugin answers with the previous deploy's
194
+ // definition, `Connect` sees a definition it already holds and emits nothing, and
195
+ // the row keeps the old structure — with no second re-detect to correct it.
196
+ //
197
+ // A bare `Output<unit>` rather than an `option<Output<_>>`: an Output is a
198
+ // JS Proxy, and wrapping one in a ReScript option produces the nested-Some
199
+ // sentinel instead of the value. Empty until `forPluginEventCollector` runs,
200
+ // which the gate reads only after `P.make()` has returned.
201
+ let eventCollectorReadyRef: ref<Pulumi.Output.t<unit>> = ref(Pulumi.Output.make())
202
+
189
203
  // Per-flavor commandHandlerConfig override for the two DCB command-handler Lambdas
190
204
  // (`<Plugin>DcbCmdHandler` sync / `<Plugin>DcbAsyncCmdHandler` async); populated by
191
205
  // Platform.MakeWithConfig from `commandHandlerConfig.stateChanges.{sync,async}`.
@@ -624,6 +638,8 @@ module Make = (
624
638
  ~opts,
625
639
  )
626
640
 
641
+ eventCollectorReadyRef := runtime.parts.lambda->Pulumi.Output.apply(_ => ())
642
+
627
643
  // Admin-only cross-plugin SNS subscription permissions. The admin EC
628
644
  // Lambda's manageSubscriptions hook (Phase 3 Step 1) creates SNS
629
645
  // subscriptions at runtime as plugins connect/disconnect; that needs
@@ -149,6 +149,10 @@ function registerHeartbeatConfig(pluginId, heartbeatTimeout, epQueueUrl, param)
149
149
  };
150
150
  }
151
151
 
152
+ let eventCollectorReadyRef = {
153
+ contents: Pulumi.output()
154
+ };
155
+
152
156
  let syncStateChangesConfigRef = {
153
157
  contents: {}
154
158
  };
@@ -381,6 +385,7 @@ function Make(EventCollectorChannel) {
381
385
  eventTopics: eventTopics,
382
386
  resources: resources
383
387
  }], runtime, opts);
388
+ eventCollectorReadyRef.contents = runtime.parts.lambda.apply(param => {});
384
389
  let isAdminEventCollector = Stdlib_Option.isNone(Plugin_Helpers$ReventlessCore.eventCollectorContextRef.contents[name]);
385
390
  if (isAdminEventCollector) {
386
391
  new (Aws.iam.RolePolicy)(name + `-snsManageSubs`, {
@@ -542,6 +547,7 @@ export {
542
547
  registerDcbTableName,
543
548
  heartbeatConfigRef,
544
549
  registerHeartbeatConfig,
550
+ eventCollectorReadyRef,
545
551
  syncStateChangesConfigRef,
546
552
  asyncStateChangesConfigRef,
547
553
  setStateChangesConfig,