@reventlessdev/reventless-seed-aws 1.0.0-alpha.10

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 (87) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +202 -0
  3. package/lib/bs/.compiler.log +2 -0
  4. package/lib/bs/.sourcedirs.json +1 -0
  5. package/lib/bs/build.ninja +0 -0
  6. package/lib/bs/compiler-info.json +8 -0
  7. package/lib/bs/src/ReventlessSeedAws.ast +0 -0
  8. package/lib/bs/src/ReventlessSeedAws.cmi +0 -0
  9. package/lib/bs/src/ReventlessSeedAws.cmj +0 -0
  10. package/lib/bs/src/ReventlessSeedAws.cmt +0 -0
  11. package/lib/bs/src/ReventlessSeedAws.res +328 -0
  12. package/lib/bs/src/ReventlessSeedAws.res.mjs +309 -0
  13. package/lib/bs/src/ReventlessSeedAws_Reconcile.ast +0 -0
  14. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmi +0 -0
  15. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmj +0 -0
  16. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmt +0 -0
  17. package/lib/bs/src/ReventlessSeedAws_Reconcile.res +389 -0
  18. package/lib/bs/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
  19. package/lib/bs/src/ReventlessSeedAws_Reset.ast +0 -0
  20. package/lib/bs/src/ReventlessSeedAws_Reset.cmi +0 -0
  21. package/lib/bs/src/ReventlessSeedAws_Reset.cmj +0 -0
  22. package/lib/bs/src/ReventlessSeedAws_Reset.cmt +0 -0
  23. package/lib/bs/src/ReventlessSeedAws_Reset.res +948 -0
  24. package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +861 -0
  25. package/lib/bs/tests/EndpointResolutionTest.ast +0 -0
  26. package/lib/bs/tests/EndpointResolutionTest.cmi +0 -0
  27. package/lib/bs/tests/EndpointResolutionTest.cmj +0 -0
  28. package/lib/bs/tests/EndpointResolutionTest.cmt +0 -0
  29. package/lib/bs/tests/EndpointResolutionTest.res +83 -0
  30. package/lib/bs/tests/EndpointResolutionTest.res.mjs +115 -0
  31. package/lib/bs/tests/ObjectStoreReconcileTest.ast +0 -0
  32. package/lib/bs/tests/ObjectStoreReconcileTest.cmi +0 -0
  33. package/lib/bs/tests/ObjectStoreReconcileTest.cmj +0 -0
  34. package/lib/bs/tests/ObjectStoreReconcileTest.cmt +0 -0
  35. package/lib/bs/tests/ObjectStoreReconcileTest.res +84 -0
  36. package/lib/bs/tests/ObjectStoreReconcileTest.res.mjs +102 -0
  37. package/lib/bs/tests/ObjectStoreResetTest.ast +0 -0
  38. package/lib/bs/tests/ObjectStoreResetTest.cmi +0 -0
  39. package/lib/bs/tests/ObjectStoreResetTest.cmj +0 -0
  40. package/lib/bs/tests/ObjectStoreResetTest.cmt +0 -0
  41. package/lib/bs/tests/ObjectStoreResetTest.res +170 -0
  42. package/lib/bs/tests/ObjectStoreResetTest.res.mjs +211 -0
  43. package/lib/ocaml/.compiler.log +2 -0
  44. package/lib/ocaml/EndpointResolutionTest.ast +0 -0
  45. package/lib/ocaml/EndpointResolutionTest.cmi +0 -0
  46. package/lib/ocaml/EndpointResolutionTest.cmj +0 -0
  47. package/lib/ocaml/EndpointResolutionTest.cmt +0 -0
  48. package/lib/ocaml/EndpointResolutionTest.res +83 -0
  49. package/lib/ocaml/ObjectStoreReconcileTest.ast +0 -0
  50. package/lib/ocaml/ObjectStoreReconcileTest.cmi +0 -0
  51. package/lib/ocaml/ObjectStoreReconcileTest.cmj +0 -0
  52. package/lib/ocaml/ObjectStoreReconcileTest.cmt +0 -0
  53. package/lib/ocaml/ObjectStoreReconcileTest.res +84 -0
  54. package/lib/ocaml/ObjectStoreResetTest.ast +0 -0
  55. package/lib/ocaml/ObjectStoreResetTest.cmi +0 -0
  56. package/lib/ocaml/ObjectStoreResetTest.cmj +0 -0
  57. package/lib/ocaml/ObjectStoreResetTest.cmt +0 -0
  58. package/lib/ocaml/ObjectStoreResetTest.res +170 -0
  59. package/lib/ocaml/ReventlessSeedAws.ast +0 -0
  60. package/lib/ocaml/ReventlessSeedAws.cmi +0 -0
  61. package/lib/ocaml/ReventlessSeedAws.cmj +0 -0
  62. package/lib/ocaml/ReventlessSeedAws.cmt +0 -0
  63. package/lib/ocaml/ReventlessSeedAws.res +328 -0
  64. package/lib/ocaml/ReventlessSeedAws_Reconcile.ast +0 -0
  65. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmi +0 -0
  66. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmj +0 -0
  67. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmt +0 -0
  68. package/lib/ocaml/ReventlessSeedAws_Reconcile.res +389 -0
  69. package/lib/ocaml/ReventlessSeedAws_Reset.ast +0 -0
  70. package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
  71. package/lib/ocaml/ReventlessSeedAws_Reset.cmj +0 -0
  72. package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
  73. package/lib/ocaml/ReventlessSeedAws_Reset.res +948 -0
  74. package/package.json +32 -0
  75. package/rescript.json +29 -0
  76. package/src/ReventlessSeedAws.res +328 -0
  77. package/src/ReventlessSeedAws.res.mjs +309 -0
  78. package/src/ReventlessSeedAws_Reconcile.res +389 -0
  79. package/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
  80. package/src/ReventlessSeedAws_Reset.res +948 -0
  81. package/src/ReventlessSeedAws_Reset.res.mjs +861 -0
  82. package/tests/EndpointResolutionTest.res +83 -0
  83. package/tests/EndpointResolutionTest.res.mjs +115 -0
  84. package/tests/ObjectStoreReconcileTest.res +84 -0
  85. package/tests/ObjectStoreReconcileTest.res.mjs +102 -0
  86. package/tests/ObjectStoreResetTest.res +170 -0
  87. package/tests/ObjectStoreResetTest.res.mjs +211 -0
@@ -0,0 +1,389 @@
1
+ // Offline reconciliation for a deployed object store: what is in the store,
2
+ // what the event log actually references, and the difference between them.
3
+ //
4
+ // This is the evidence that makes enabling a store's expiry rule safe — the one
5
+ // mechanism that can *prove* an object is unreferenced, which is why it is an
6
+ // operator-run report and not a request handler. Read-only from start to finish:
7
+ // it lists, it scans, it prints. Nothing here deletes anything, and it needs
8
+ // none of the reset tool's fail-closed gates because there is nothing to fail
9
+ // closed about.
10
+ //
11
+ // The question it answers is the one the lifecycle rule is about to answer
12
+ // destructively: **is every object still tagged pending genuinely unreferenced?**
13
+ // A single row in the "referenced but still tagged" section is a bug caught on
14
+ // a report instead of on a page.
15
+ //
16
+ // ## Why it does not read the `@storageRef` declarations
17
+ //
18
+ // The claim component is declaration-driven: it untags the refs that annotated
19
+ // fields carry. If reconciliation read the same declarations it would inherit
20
+ // the same blind spot, and the two would agree precisely where they were both
21
+ // wrong — a field nobody annotated holds a ref, the claimer never strips its
22
+ // tag, and a report derived from the same list would call the object
23
+ // unreferenced too. So this walks each event's payload for *any* string under
24
+ // the store's prefix.
25
+ //
26
+ // That is deliberately generous, and generous is the safe direction here: it can
27
+ // only ever move an object from "unreferenced" to "referenced", never the
28
+ // reverse. An unannotated ref therefore shows up exactly where it should — in
29
+ // the referenced-but-tagged section, as the thing to fix before turning expiry
30
+ // on.
31
+ //
32
+ // ## Cost
33
+ //
34
+ // A full scan of every event log table and a tagging read per object. Minutes on
35
+ // a demo store, longer on a real one, and both are fine for something an
36
+ // operator runs deliberately before a one-way decision.
37
+
38
+ open ReventlessSeed
39
+
40
+ module Ddb = AwsSdk.DynamoDb
41
+ module Rgt = AwsSdk.ResourceGroupsTaggingApi
42
+ module S3 = AwsSdk.S3
43
+
44
+ // The tag the mint side writes and the claim component removes, restated rather
45
+ // than imported: the seed packages deliberately depend on no framework package —
46
+ // they drive a platform through its public API — so `Upload_PendingTag.key` is
47
+ // out of reach here.
48
+ //
49
+ // That makes this a duplicated constant, and the honest thing is to say what
50
+ // happens if it drifts rather than to claim it cannot. Reading the wrong key
51
+ // would report every object as untagged: a clean bill of health for a store this
52
+ // tool had not actually inspected. `print` therefore calls out the shape that
53
+ // drift produces — objects present, none of them tagged — instead of leaving it
54
+ // as a silent pass. Changing this string means changing `Upload_PendingTag.key`
55
+ // in reventless-aws, and vice versa.
56
+ let pendingTagKey = "reventless:pending"
57
+
58
+ // ── Collecting refs from the event log ───────────────────────────────────────
59
+
60
+ /** Every string under `/{prefix}/` anywhere in a committed event's payload.
61
+
62
+ Recursive because an event's payload is whatever its schema says: a ref can
63
+ sit at the top level, inside an array, or nested in a record. Depth costs
64
+ nothing here and missing a ref costs an object. */
65
+ let rec collectRefs = (~prefix: string, json: JSON.t, into: Set.t<string>): unit =>
66
+ switch json {
67
+ | String(s) =>
68
+ if s->String.startsWith(`/${prefix}/`) {
69
+ into->Set.add(s)
70
+ }
71
+ | Array(items) => items->Array.forEach(item => collectRefs(~prefix, item, into))
72
+ | Object(fields) => fields->Dict.valuesToArray->Array.forEach(v => collectRefs(~prefix, v, into))
73
+ | _ => ()
74
+ }
75
+
76
+ /** Scan one table for committed events and collect the refs they carry.
77
+
78
+ Both the aggregate and the DCB event log store a row as
79
+ `{event: "<Type>", data: {…}}`, so a row without an `event` attribute is not
80
+ an event — a snapshot, a DCB fence row, or a read-model row in a table that
81
+ is not an event log at all. That single check is what lets this scan every
82
+ discovered table without having to know which ones are event logs. */
83
+ let refsInTable = async (~table: string, ~prefix: string, into: Set.t<string>): int => {
84
+ let rec loop = async (start: option<dict<JSON.t>>, rows: int): int => {
85
+ let out = await Ddb.DocumentClient.ScanCommand.send(
86
+ Ddb.DocumentClient.ScanCommand.make({tableName: table, exclusiveStartKey: ?start}),
87
+ )
88
+ let items = out.items->Option.getOr([])
89
+ items->Array.forEach(item =>
90
+ switch item->JSON.Decode.object {
91
+ | Some(row) =>
92
+ switch (row->Dict.get("event"), row->Dict.get("data")) {
93
+ | (Some(JSON.String(_)), Some(data)) => collectRefs(~prefix, data, into)
94
+ | _ => ()
95
+ }
96
+ | None => ()
97
+ }
98
+ )
99
+ let rows = rows + items->Array.length
100
+ switch out.lastEvaluatedKey {
101
+ | Some(k) => await loop(Some(k), rows)
102
+ | None => rows
103
+ }
104
+ }
105
+ await loop(None, 0)
106
+ }
107
+
108
+ // ── Collecting objects from the store ────────────────────────────────────────
109
+
110
+ /** One object in the store, and whether it is still provisional. */
111
+ type object = {
112
+ key: string,
113
+ /** The ref an event would carry for this object — the key with a leading `/`,
114
+ which is exactly what the presign service minted. */
115
+ ref: string,
116
+ pending: bool,
117
+ }
118
+
119
+ let listObjects = async (~bucket: string, ~prefix: string): array<object> => {
120
+ let keys = []
121
+ let rec loop = async (keyMarker, versionMarker): unit => {
122
+ let out = await S3.ListObjectVersionsCommand.send(
123
+ S3.ListObjectVersionsCommand.make({
124
+ bucket,
125
+ prefix: `${prefix}/`,
126
+ keyMarker: ?keyMarker,
127
+ versionIdMarker: ?versionMarker,
128
+ }),
129
+ )
130
+ // Current objects only. Store buckets are created with versioning off, so
131
+ // in practice every version is the latest one; filtering keeps this honest
132
+ // if a deployment ever turns versioning on.
133
+ out.versions
134
+ ->Option.getOr([])
135
+ ->Array.forEach(v =>
136
+ if v.isLatest {
137
+ keys->Array.push(v.key)
138
+ }
139
+ )
140
+ switch (out.isTruncated, out.nextKeyMarker) {
141
+ | (Some(true), Some(_)) => await loop(out.nextKeyMarker, out.nextVersionIdMarker)
142
+ | _ => ()
143
+ }
144
+ }
145
+ await loop(None, None)
146
+
147
+ // One tagging read per object. Sequential on purpose — this competes with a
148
+ // live claim component for the same objects' tagging reads, and an operator
149
+ // report has no deadline worth throttling production for.
150
+ let objects = []
151
+ await keys->Array.reduce(Promise.resolve(), (acc, key) =>
152
+ acc->Promise.then(async _ => {
153
+ let tagging = await S3.GetObjectTaggingCommand.send(
154
+ S3.GetObjectTaggingCommand.make({bucket, key}),
155
+ )
156
+ let pending =
157
+ tagging.tagSet->Option.getOr([])->Array.some(t => t.key == pendingTagKey)
158
+ objects->Array.push({key, ref: `/${key}`, pending})
159
+ })
160
+ )
161
+ objects
162
+ }
163
+
164
+ // ── The report ───────────────────────────────────────────────────────────────
165
+
166
+ /** The four populations, which are the whole point. Only one of them is a
167
+ problem, and it is the one the lifecycle rule would delete. */
168
+ type report = {
169
+ store: string,
170
+ bucket: string,
171
+ prefix: string,
172
+ eventRows: int,
173
+ /** Referenced by a committed event and STILL TAGGED PENDING. Enabling expiry
174
+ would delete these. Must be empty before a store turns the rule on. */
175
+ referencedButPending: array<object>,
176
+ /** Tagged pending and referenced by nothing — what the rule is for. */
177
+ unreferencedPending: array<object>,
178
+ /** Referenced by nothing and carrying no tag: minted before the claim
179
+ component existed, so outside the rule entirely and never collected. */
180
+ unreferencedUntagged: array<object>,
181
+ /** An event references it and the object is not there. Not this mechanism's
182
+ doing — expiry only ever removes untagged-by-nobody objects — but a broken
183
+ image is worth surfacing while everything is being counted anyway. */
184
+ danglingRefs: array<string>,
185
+ }
186
+
187
+ let reconcileStore = async (
188
+ ~store: string,
189
+ ~bucket: string,
190
+ ~prefix: string,
191
+ ~tables: array<string>,
192
+ ): report => {
193
+ let refs = Set.make()
194
+ let eventRows = ref(0)
195
+ await tables->Array.reduce(Promise.resolve(), (acc, table) =>
196
+ acc->Promise.then(async _ => {
197
+ let rows = await refsInTable(~table, ~prefix, refs)
198
+ eventRows := eventRows.contents + rows
199
+ })
200
+ )
201
+
202
+ let objects = await listObjects(~bucket, ~prefix)
203
+ let present = Set.make()
204
+ objects->Array.forEach(o => present->Set.add(o.ref))
205
+
206
+ {
207
+ store,
208
+ bucket,
209
+ prefix,
210
+ eventRows: eventRows.contents,
211
+ referencedButPending: objects->Array.filter(o => o.pending && refs->Set.has(o.ref)),
212
+ unreferencedPending: objects->Array.filter(o => o.pending && !(refs->Set.has(o.ref))),
213
+ unreferencedUntagged: objects->Array.filter(o => !o.pending && !(refs->Set.has(o.ref))),
214
+ danglingRefs: refs->Set.toArray->Array.filter(r => !(present->Set.has(r))),
215
+ }
216
+ }
217
+
218
+ let sample = (objects: array<object>, ~limit: int=10): array<object> =>
219
+ objects->Array.slice(~start=0, ~end=limit)
220
+
221
+ /** Print one store's report. Returns `true` when the store is safe to enable
222
+ expiry on — that is, when nothing referenced is still tagged. */
223
+ let print = (r: report): bool => {
224
+ Console.log("")
225
+ Console.log(` ${r.store} ${r.bucket}/${r.prefix}/`)
226
+ Console.log(` event rows scanned: ${r.eventRows->Int.toString}`)
227
+ let line = (label, n) => Console.log(` ${n->Int.toString->String.padStart(8, " ")} ${label}`)
228
+ line("referenced, still tagged pending ← must be 0", r.referencedButPending->Array.length)
229
+ line("unreferenced, tagged pending ← what expiry would delete", r.unreferencedPending->Array.length)
230
+ line("unreferenced, untagged ← outside the rule", r.unreferencedUntagged->Array.length)
231
+ line("referenced but missing ← dangling refs", r.danglingRefs->Array.length)
232
+
233
+ // Every object untagged reads as "the claimer has caught up with everything",
234
+ // and that is usually what it is. But it is also exactly what a drifted tag key
235
+ // looks like, and the two are indistinguishable from the counts alone — so say
236
+ // so rather than let a store pass on evidence that might mean nothing.
237
+ let objectCount =
238
+ r.referencedButPending->Array.length +
239
+ r.unreferencedPending->Array.length +
240
+ r.unreferencedUntagged->Array.length
241
+ if objectCount > 0 && r.referencedButPending->Array.length == 0 && r.unreferencedPending->Array.length == 0 {
242
+ Console.log("")
243
+ Console.log(
244
+ ` Note: none of the ${objectCount->Int.toString} objects carry "${pendingTagKey}". That is ` ++
245
+ "what a fully-claimed store looks like — and also what it looks like if the mint side " ++
246
+ "never wrote the tag (a store whose objects all predate it) or if this tool's tag key has " ++
247
+ "drifted from the deploy's.",
248
+ )
249
+ }
250
+
251
+ if r.referencedButPending->Array.length > 0 {
252
+ Console.log("")
253
+ Console.log(" Referenced objects that are still tagged pending:")
254
+ sample(r.referencedButPending)->Array.forEach(o => Console.log(` ${o.ref}`))
255
+ if r.referencedButPending->Array.length > 10 {
256
+ Console.log(` … and ${(r.referencedButPending->Array.length - 10)->Int.toString} more`)
257
+ }
258
+ Console.log("")
259
+ Console.log(
260
+ " Do NOT enable this store's expiry rule. Either the claim component is behind or " ++
261
+ "stopped (check its IteratorAge alarm), or a field holding these refs is missing its " ++
262
+ "`@storageRef` annotation — in which case the claimer never sees it and never will.",
263
+ )
264
+ }
265
+ r.referencedButPending->Array.length == 0
266
+ }
267
+
268
+ /**
269
+ * Reconcile every declared object store of a deployed stack.
270
+ *
271
+ * Reads the platform project's `objectStores` output for the store list, and
272
+ * discovers each plugin project's DynamoDB tables by the framework's
273
+ * `reventless:platform` + `reventless:environment` tags — the same scoping the
274
+ * reset uses, for the same reason: a stack name alone is shared across projects.
275
+ *
276
+ * `targets` are the deployment's Pulumi projects, exactly as `Reset.run` takes
277
+ * them. Exits non-zero when any store has a referenced object still tagged
278
+ * pending, so a scheduled run of this is a usable check and not just a page of
279
+ * numbers.
280
+ */
281
+ let run = (~stack=?, ~backend=?, ~targets: array<ReventlessSeedAws_Reset.target>, ()): unit => {
282
+ let go = async () => {
283
+ try {
284
+ if targets->Array.length == 0 {
285
+ throw(Seed.Failed("no targets were declared — nothing to reconcile."))
286
+ }
287
+ let backend = switch Seed.Prompt.envValue("SEED_PULUMI_BACKEND") {
288
+ | Some(url) => Some(url)
289
+ | None => backend
290
+ }
291
+ let platformTarget = switch targets->Array.find(t => t.group == Platform) {
292
+ | Some(t) => t
293
+ | None =>
294
+ throw(
295
+ Seed.Failed(
296
+ "no `platform` target is declared — declared object stores live in the platform " ++
297
+ "project's stack output and cannot be resolved without it.",
298
+ ),
299
+ )
300
+ }
301
+ let stack = await ReventlessSeedAws.resolveStack(
302
+ ~projectDir=platformTarget.projectDir,
303
+ ~backend,
304
+ ~stack,
305
+ )
306
+
307
+ let output = ReventlessSeedAws.stackOutputs(
308
+ ~projectDir=platformTarget.projectDir,
309
+ ~backend,
310
+ stack,
311
+ )
312
+ let stores = switch ReventlessSeedAws_Reset.parseObjectStores(
313
+ output->ReventlessSeedAws_Reset.field("objectStores"),
314
+ ) {
315
+ | Ok(stores) => stores
316
+ | Error(message) => throw(Seed.Failed(message))
317
+ }
318
+ if stores->Array.length == 0 {
319
+ Seed.Runner.heading(`Reconcile: stack "${stack}" declares no object stores.`)
320
+ } else {
321
+ let region = switch Seed.Prompt.envValue("AWS_REGION") {
322
+ | Some(r) if r != "" => r
323
+ | _ =>
324
+ throw(Seed.Failed("could not resolve the AWS region — set AWS_REGION."))
325
+ }
326
+ Seed.Runner.heading(`Reconcile: stack "${stack}" in ${region}`)
327
+
328
+ // A store's objects are referenced by the events of the plugin that
329
+ // declared it — but a qualified `@storageRef("Other.store")` means
330
+ // another plugin's events can carry them too, so every target's tables
331
+ // are scanned for every store rather than only the owner's.
332
+ let tables = []
333
+ await targets->Array.reduce(Promise.resolve(), (acc, target) =>
334
+ acc->Promise.then(async _ => {
335
+ let platform = ReventlessSeedAws_Reset.projectName(~projectDir=target.projectDir)
336
+ let (found, _buckets) = await ReventlessSeedAws_Reset.discover(
337
+ ~region,
338
+ ~stack,
339
+ ~platform,
340
+ )
341
+ found->Array.forEach(t => tables->Array.push(t))
342
+ })
343
+ )
344
+
345
+ let allClear = ref(true)
346
+ await stores->Array.reduce(Promise.resolve(), (acc, s) =>
347
+ acc->Promise.then(async _ => {
348
+ let report = await reconcileStore(
349
+ ~store=s.qualified,
350
+ ~bucket=s.bucketName,
351
+ ~prefix=s.keyPrefix,
352
+ ~tables,
353
+ )
354
+ if !print(report) {
355
+ allClear := false
356
+ }
357
+ })
358
+ )
359
+ Console.log("")
360
+ if allClear.contents {
361
+ Console.log(
362
+ "Every referenced object has been claimed. Enabling a store's expiry rule " ++
363
+ "(`pendingUploadExpiryDays: \"<plugin>.<store>=<days>\"`) would delete only " ++
364
+ "objects nothing references.",
365
+ )
366
+ } else {
367
+ throw(
368
+ Seed.Failed(
369
+ "at least one referenced object is still tagged pending — see above. No store's " ++
370
+ "expiry rule should be enabled while this is true.",
371
+ ),
372
+ )
373
+ }
374
+ }
375
+ NodeProcess.exit(0)
376
+ } catch {
377
+ | Seed.Failed(message) =>
378
+ Console.error("")
379
+ Console.error(`Reconcile failed — ${message}`)
380
+ NodeProcess.exit(1)
381
+ | exn =>
382
+ Console.error("")
383
+ Console.error("Reconcile failed with an unexpected error:")
384
+ Console.error(exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"))
385
+ NodeProcess.exit(1)
386
+ }
387
+ }
388
+ go()->ignore
389
+ }