@reventlessdev/reventless-aws 3.0.0-alpha.217 → 3.0.0-alpha.219

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 (34) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +4 -4
  3. package/src/Platform.res.mjs +2 -0
  4. package/src/adapter/Api/AppSync_SourceApiAssociation_Retrying.res +436 -0
  5. package/src/adapter/Api/AppSync_SourceApiAssociation_Retrying.res.mjs +340 -0
  6. package/src/adapter/Mcp/MCP_Lambda.res +8 -19
  7. package/src/adapter/Mcp/MCP_Lambda.res.mjs +2 -9
  8. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +6 -2
  9. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +12 -2
  10. package/src/components/Aggregate_Builder_Micro.res.mjs +1 -0
  11. package/src/components/Aggregate_Builder_NoResolver.res.mjs +1 -0
  12. package/src/components/Aggregate_Builder_PerAggregate.res.mjs +1 -0
  13. package/src/components/Aggregate_Builder_Single.res.mjs +1 -0
  14. package/src/components/Aggregate_Builder_Single_Async.res.mjs +1 -0
  15. package/src/components/Api/AppSync_MergedApi.res +19 -16
  16. package/src/components/Api/AppSync_MergedApi.res.mjs +5 -6
  17. package/src/components/AutomationSlice_Builder.res.mjs +1 -0
  18. package/src/components/ExtensionPoint_Builder.res.mjs +1 -0
  19. package/src/components/ForeignReadModel_Builder.res.mjs +2 -0
  20. package/src/components/OutboundTranslationSlice_Builder.res.mjs +1 -0
  21. package/src/components/Plugin.res.mjs +1 -0
  22. package/src/components/ReadModel_Builder_NoResolver.res.mjs +1 -0
  23. package/src/components/ReadModel_Builder_NoResolver_Stream.res.mjs +1 -0
  24. package/src/components/ReadModel_Builder_PerReadModel.res.mjs +1 -0
  25. package/src/components/ReadModel_Builder_Single.res.mjs +1 -0
  26. package/src/components/ReadModel_Builder_Single_Stream.res.mjs +1 -0
  27. package/src/components/SideEffectHandler_PerSideEffectHandler.res.mjs +3 -0
  28. package/src/components/SideEffectHandler_Single.res.mjs +2 -0
  29. package/src/components/StateViewSlice_Builder.res.mjs +1 -0
  30. package/src/components/StateViewSlice_Builder_Stream.res.mjs +1 -0
  31. package/src/components/Task_Builder_PerBucket.res.mjs +2 -0
  32. package/src/plugin/stack/Plugin_ExtensionPoint_Builder.res.mjs +2 -0
  33. package/tests/AppSync_SourceApiAssociation_RetryingTest.res +112 -0
  34. package/tests/AppSync_SourceApiAssociation_RetryingTest.res.mjs +133 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
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.219 (2026-07-21)
7
+
8
+ ### Features
9
+
10
+ * **aws:** retrying SourceApiAssociation provider for concurrent merged-API deploys ([446a8fe](https://github.com/ReventlessDev/reventless-core/commit/446a8fe77ae9ad94a46eb58ec746f56130974f8e))
11
+ * **core:** populate pluginName + route MCP dispatch through shared helper ([06ec4f9](https://github.com/ReventlessDev/reventless-core/commit/06ec4f98b2dc23a8ef547a754d140e48268ee183))
12
+
13
+
14
+ # 3.0.0-alpha.218 (2026-07-20)
15
+
16
+ ### Features
17
+
18
+ * **core:** annotate comp + causationId at the dispatch boundary ([ef6d56d](https://github.com/ReventlessDev/reventless-core/commit/ef6d56d0104dbdb21b7cfbb8f3b2b1ead9787595))
19
+
20
+
6
21
  # 3.0.0-alpha.217 (2026-07-19)
7
22
 
8
23
  **Note:** Version bump only for package @reventlessdev/reventless-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.217",
3
+ "version": "3.0.0-alpha.219",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -11,13 +11,13 @@
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.24",
12
12
  "@reventlessdev/rescript-effect": "0.1.0-alpha.29",
13
13
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
14
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.56",
15
14
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
15
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.56",
16
16
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
17
- "@reventlessdev/reventless-core": "3.0.0-alpha.172",
17
+ "@reventlessdev/reventless-core": "3.0.0-alpha.174",
18
18
  "@reventlessdev/reventless-infra": "3.0.0-alpha.102",
19
19
  "@reventlessdev/reventless-interop": "3.0.0-alpha.28",
20
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.36",
20
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.38",
21
21
  "@reventlessdev/reventless-spec": "3.0.0-alpha.79"
22
22
  },
23
23
  "devDependencies": {
@@ -541,6 +541,7 @@ function MakeWithConfig(Config) {
541
541
  make: RuntimeEnvironment_Lambda$ReventlessAws.make,
542
542
  groupBySource: RuntimeEnvironment_Lambda$ReventlessAws.groupBySource,
543
543
  extractCorrelationId: RuntimeEnvironment_Lambda$ReventlessAws.extractCorrelationId,
544
+ extractCausationId: RuntimeEnvironment_Lambda$ReventlessAws.extractCausationId,
544
545
  asEventHandler: prim => prim,
545
546
  asEffectHandler: prim => prim
546
547
  })({
@@ -1513,6 +1514,7 @@ function Make($star) {
1513
1514
  make: RuntimeEnvironment_Lambda$ReventlessAws.make,
1514
1515
  groupBySource: RuntimeEnvironment_Lambda$ReventlessAws.groupBySource,
1515
1516
  extractCorrelationId: RuntimeEnvironment_Lambda$ReventlessAws.extractCorrelationId,
1517
+ extractCausationId: RuntimeEnvironment_Lambda$ReventlessAws.extractCausationId,
1516
1518
  asEventHandler: prim => prim,
1517
1519
  asEffectHandler: prim => prim
1518
1520
  })({
@@ -0,0 +1,436 @@
1
+ /** AppSync SourceApiAssociation backed by a Pulumi dynamic provider that
2
+ retries CreateSourceApiAssociation (and Delete) on the per-merged-API
3
+ concurrency race:
4
+
5
+ ConcurrentModificationException (HTTP 409)
6
+
7
+ AWS serializes association writes per merged API. When plugin stacks deploy
8
+ in parallel, their first-time associations against the *same* merged API
9
+ race and 409 — the classic `aws:appsync/sourceApiAssociation` provider does
10
+ NOT retry this, so the losing stack's `pulumi up` fails. This provider owns
11
+ the Create / Delete / Get SDK calls and retries the 409 (plus sibling
12
+ transient AWS errors) with capped exponential backoff. It is the Phase-4
13
+ fix that lets plugin deploys run concurrently again without a CI-level
14
+ `max-parallel: 1` serialization (docs/plans/done/merged-api-push-free-composition.md,
15
+ "Association creation 409s under concurrency").
16
+
17
+ Steady-state is unaffected: under AUTO_MERGE, AWS re-merges automatically on
18
+ every source-API schema change with no further association calls, so the
19
+ 409 is a creation/deletion-time race only.
20
+
21
+ The initial merge is asynchronous (AUTO_MERGE schedules it); this provider
22
+ returns as soon as the association record exists. Waiting for MERGE_SUCCESS
23
+ stays the caller's job via `AppSync_MergedApi.mergeStatusGateWith`, so a
24
+ failed merge still fails the deploy loudly.
25
+
26
+ State migration: aliases the classic
27
+ `aws:appsync/sourceApiAssociation:SourceApiAssociation` type so existing
28
+ associations are adopted in-place on first deploy (no delete+recreate → no
29
+ merge blip on the shared endpoint).
30
+
31
+ Mirrors `AppSync_Resolver_Retrying`'s dynImport + hand-rolled-retry shape:
32
+ Pulumi serialises the dynamic provider's whole captured closure into stack
33
+ state, and `Effect` (used by the in-process `AppSync_Error` retry) fails
34
+ that serialisation, so the SDK is lazily imported and the backoff loop is
35
+ hand-rolled here. */
36
+
37
+ let log = ReventlessCore.Logger.fromEnv()
38
+
39
+ // ── AWS SDK bindings (lazily imported — see AppSync_Resolver_Retrying) ─────────
40
+
41
+ type appSyncClient
42
+
43
+ type mergeConfig = {mergeType: string}
44
+
45
+ type createInput = {
46
+ mergedApiIdentifier: string,
47
+ sourceApiIdentifier: string,
48
+ sourceApiAssociationConfig?: mergeConfig,
49
+ }
50
+
51
+ type assocSummary = {
52
+ associationId?: string,
53
+ associationArn?: string,
54
+ }
55
+ type createResult = {sourceApiAssociation?: assocSummary}
56
+ type getResult = {sourceApiAssociation?: assocSummary}
57
+
58
+ type idInput = {associationId: string, mergedApiIdentifier: string}
59
+
60
+ type createCmd
61
+ type getCmd
62
+ type deleteCmd
63
+
64
+ // Shape of the dynamically-imported `@aws-sdk/client-appsync` module. Only the
65
+ // three association commands are needed here.
66
+ type sdkModule = {
67
+ @as("AppSyncClient") clientCtor: unit => appSyncClient,
68
+ @as("CreateSourceApiAssociationCommand") createCtor: createInput => createCmd,
69
+ @as("GetSourceApiAssociationCommand") getCtor: idInput => getCmd,
70
+ @as("DeleteSourceApiAssociationCommand") deleteCtor: idInput => deleteCmd,
71
+ }
72
+
73
+ // Calls a constructor with `new` — the SDK exports plain classes.
74
+ let newOf1: ('ctor, 'arg) => 'r = %raw(`(C, x) => new C(x)`)
75
+ let newOf0: 'ctor => 'r = %raw(`(C) => new C()`)
76
+
77
+ @send external sendCreate: (appSyncClient, createCmd) => promise<createResult> = "send"
78
+ @send external sendGet: (appSyncClient, getCmd) => promise<getResult> = "send"
79
+ @send external sendDelete: (appSyncClient, deleteCmd) => promise<unit> = "send"
80
+
81
+ // Dynamic ESM import — emitted as a literal `import("...")` so the bundler /
82
+ // Pulumi serialiser does not statically capture the module in the closure.
83
+ @val external dynImport: string => promise<sdkModule> = "import"
84
+
85
+ let _sdk: ref<option<sdkModule>> = ref(None)
86
+ let _client: ref<option<appSyncClient>> = ref(None)
87
+
88
+ let getSdk = async (): sdkModule =>
89
+ switch _sdk.contents {
90
+ | Some(m) => m
91
+ | None =>
92
+ let m = await dynImport("@aws-sdk/client-appsync")
93
+ _sdk.contents = Some(m)
94
+ m
95
+ }
96
+
97
+ let getClient = async (): appSyncClient =>
98
+ switch _client.contents {
99
+ | Some(c) => c
100
+ | None =>
101
+ let sdk = await getSdk()
102
+ let c = newOf0(sdk.clientCtor)
103
+ _client.contents = Some(c)
104
+ c
105
+ }
106
+
107
+ // ── Error classification ──────────────────────────────────────────────────────
108
+
109
+ @get @return(nullable) external exnName: JsExn.t => option<string> = "name"
110
+
111
+ // Transient AWS errors worth retrying — the same set `AppSync_Error.classify`
112
+ // treats as Transient, matched here by exception name OR message (the SDK
113
+ // surfaces the code in either depending on the error path).
114
+ let transientNames = [
115
+ "ConcurrentModificationException",
116
+ "ThrottlingException",
117
+ "TooManyRequestsException",
118
+ "InternalFailureException",
119
+ "ServiceUnavailableException",
120
+ "ServiceUnavailable",
121
+ ]
122
+
123
+ /** Returns `true` iff the error is the per-merged-API association concurrency
124
+ race: `ConcurrentModificationException` (HTTP 409). Exposed for testing. */
125
+ let isConcurrentModificationError = (jsErr: JsExn.t): bool =>
126
+ switch (jsErr->exnName, JsExn.message(jsErr)) {
127
+ | (Some("ConcurrentModificationException"), _) => true
128
+ | (_, Some(msg)) => msg->String.includes("ConcurrentModificationException")
129
+ | _ => false
130
+ }
131
+
132
+ /** Returns `true` for any transient AWS error the create/delete loop should
133
+ retry (the 409 race plus throttling / transient service errors). */
134
+ let isRetryableAssociationError = (jsErr: JsExn.t): bool =>
135
+ switch (jsErr->exnName, JsExn.message(jsErr)) {
136
+ | (Some(name), _) if transientNames->Array.includes(name) => true
137
+ | (_, Some(msg)) => transientNames->Array.some(n => msg->String.includes(n))
138
+ | _ => false
139
+ }
140
+
141
+ /** Returns `true` iff the target association is already gone, so a delete /
142
+ read should be treated as a no-op / drift. AWS reports a missing merged API
143
+ or association as `NotFoundException`. */
144
+ let isAlreadyGoneError = (jsErr: JsExn.t): bool =>
145
+ switch (jsErr->exnName, JsExn.message(jsErr)) {
146
+ | (Some("NotFoundException"), _) => true
147
+ | (_, Some(msg)) => msg->String.includes("NotFoundException") || msg->String.includes("not found")
148
+ | _ => false
149
+ }
150
+
151
+ // ── Retry helper ─────────────────────────────────────────────────────────────
152
+
153
+ @val external setTimeout: (unit => unit, int) => int = "setTimeout"
154
+
155
+ /** Throws a JavaScript exception directly (bypasses the ReScript exn wrapper),
156
+ so Pulumi displays AWS's real message rather than "error: undefined". */
157
+ let jsThrow: JsExn.t => 'a = %raw(`e => { throw e }`)
158
+
159
+ /** Hand-rolled retry on the transient AppSync association races with capped
160
+ exponential backoff. Delay starts at 2 s, doubles each attempt, caps at
161
+ 30 s; up to 8 attempts → ~150 s budget — enough to outlast several serial
162
+ per-merged-API merges (each ~12 s, spike-measured) when a handful of plugin
163
+ stacks associate against one merged API at once. Beyond that, failing loud
164
+ beats retrying forever. */
165
+ let rec runWithRetry = async (
166
+ ~attempt: int=0,
167
+ ~maxAttempts: int=8,
168
+ ~delayMs: int=2000,
169
+ ~maxDelayMs: int=30000,
170
+ makeCall: unit => promise<'a>,
171
+ ): 'a => {
172
+ try {
173
+ await makeCall()
174
+ } catch {
175
+ | exn =>
176
+ let jsExn = exn->JsExn.fromException
177
+ let name = jsExn->Option.flatMap(exnName)->Option.getOr("(no name)")
178
+ let msg = jsExn->Option.flatMap(JsExn.message)->Option.getOr("(no message)")
179
+ let isRetryable =
180
+ attempt < maxAttempts &&
181
+ jsExn->Option.mapOr(false, isRetryableAssociationError)
182
+ if isRetryable {
183
+ log.info(
184
+ ~comp="AppSync_SourceApiAssociation_Retrying",
185
+ `attempt ${(attempt + 1)->Int.toString}/${maxAttempts->Int.toString} failed, retrying in ${delayMs->Int.toString}ms: ${name}: ${msg}`,
186
+ )
187
+ let _ = await Promise.make((resolve, _) => setTimeout(resolve, delayMs)->ignore)
188
+ let nextDelay = delayMs * 2
189
+ let cappedDelay = nextDelay > maxDelayMs ? maxDelayMs : nextDelay
190
+ await runWithRetry(~attempt=attempt + 1, ~maxAttempts, ~delayMs=cappedDelay, ~maxDelayMs, makeCall)
191
+ } else {
192
+ if attempt > 0 {
193
+ log.warn(
194
+ ~comp="AppSync_SourceApiAssociation_Retrying",
195
+ `giving up after ${(attempt + 1)->Int.toString} attempts: ${name}: ${msg}`,
196
+ )
197
+ }
198
+ switch jsExn {
199
+ | Some(e) => jsThrow(e)
200
+ | None => throw(exn)
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ // ── Identifier extraction ─────────────────────────────────────────────────────
207
+ //
208
+ // Pulumi passes OUTPUTS (not inputs) to delete/read handlers, and an adopted
209
+ // classic resource carries a different output shape than this provider writes.
210
+ // The resource `id` is always present, so derive (mergedApiId, associationId)
211
+ // from it, falling back to stored props for older/mixed state.
212
+ //
213
+ // - this provider's own id = the association ARN:
214
+ // arn:aws:appsync:<region>:<acct>:apis/<mergedApiId>/sourceApiAssociations/<assocId>
215
+ // - the classic provider's id = "<mergedApiId>,<assocId>"
216
+
217
+ let idsFromArn = (arn: string): option<idInput> => {
218
+ let parts = arn->String.split("/")
219
+ let idx = parts->Array.indexOf("sourceApiAssociations")
220
+ switch (idx >= 1, parts->Array.get(idx - 1), parts->Array.get(idx + 1)) {
221
+ | (true, Some(mergedApiId), Some(assocId)) =>
222
+ Some({mergedApiIdentifier: mergedApiId, associationId: assocId})
223
+ | _ => None
224
+ }
225
+ }
226
+
227
+ let idsFromComposite = (id: string): option<idInput> =>
228
+ switch id->String.split(",") {
229
+ | [mergedApiId, assocId] => Some({mergedApiIdentifier: mergedApiId, associationId: assocId})
230
+ | _ => None
231
+ }
232
+
233
+ // ── Provider input / output shapes ────────────────────────────────────────────
234
+
235
+ type providerInputs = {
236
+ mergedApiIdentifier: string,
237
+ sourceApiIdentifier: string,
238
+ mergeType: string,
239
+ }
240
+
241
+ // `arn` / `associationId` are named to match PulumiAws.AppSync.SourceApiAssociation.t
242
+ // so consumers (mergeStatusGateWith) read `.associationId` unchanged. All fields
243
+ // diff_ compares must live here or `olds.field` is undefined at diff time.
244
+ type createOuts = {
245
+ arn: string,
246
+ associationId: string,
247
+ mergedApiIdentifier: string,
248
+ sourceApiIdentifier: string,
249
+ mergeType: string,
250
+ }
251
+ type createResultOut = {id: string, outs: createOuts}
252
+ type updateResultOut = {outs: createOuts}
253
+ type diffResult = {changes: bool, replaces: array<string>, deleteBeforeReplace: bool}
254
+ type readResult = {id?: string, props?: createOuts}
255
+
256
+ // Extract from stored outs OR classic-shaped props via the id, so delete/read
257
+ // work for both this provider's resources and freshly-adopted classic ones.
258
+ let identifiersFrom = (~id: string, ~props: createOuts): option<idInput> =>
259
+ switch idsFromArn(id) {
260
+ | Some(ids) => Some(ids)
261
+ | None =>
262
+ switch idsFromComposite(id) {
263
+ | Some(ids) => Some(ids)
264
+ | None =>
265
+ // Last resort: this provider's own stored outs.
266
+ switch idsFromArn(props.arn) {
267
+ | Some(ids) => Some(ids)
268
+ | None => None
269
+ }
270
+ }
271
+ }
272
+
273
+ // ── Provider methods ──────────────────────────────────────────────────────────
274
+
275
+ let extractIds = (resp: createResult): (string, string) =>
276
+ switch resp.sourceApiAssociation {
277
+ | Some({associationArn: ?Some(arn), associationId: ?Some(aid)}) => (arn, aid)
278
+ | _ =>
279
+ JsError.throwWithMessage(
280
+ "CreateSourceApiAssociation returned no associationArn / associationId",
281
+ )
282
+ }
283
+
284
+ let create = async (inputs: providerInputs): createResultOut => {
285
+ let sdk = await getSdk()
286
+ let client = await getClient()
287
+ let (arn, associationId) = await runWithRetry(() =>
288
+ client
289
+ ->sendCreate(
290
+ newOf1(
291
+ sdk.createCtor,
292
+ {
293
+ mergedApiIdentifier: inputs.mergedApiIdentifier,
294
+ sourceApiIdentifier: inputs.sourceApiIdentifier,
295
+ sourceApiAssociationConfig: {mergeType: inputs.mergeType},
296
+ },
297
+ ),
298
+ )
299
+ ->Promise.thenResolve(extractIds)
300
+ )
301
+ {
302
+ id: arn,
303
+ outs: {
304
+ arn,
305
+ associationId,
306
+ mergedApiIdentifier: inputs.mergedApiIdentifier,
307
+ sourceApiIdentifier: inputs.sourceApiIdentifier,
308
+ mergeType: inputs.mergeType,
309
+ },
310
+ }
311
+ }
312
+
313
+ // No in-place mutation path: identity changes force replace (see diff_), and
314
+ // AUTO_MERGE re-merges are automatic, so update is only ever reached for a
315
+ // no-op reconcile — return the stored outs unchanged.
316
+ let update = async (_id: string, olds: createOuts, _news: providerInputs): updateResultOut => {
317
+ {outs: olds}
318
+ }
319
+
320
+ let delete_ = async (id: string, props: createOuts): unit => {
321
+ let sdk = await getSdk()
322
+ let client = await getClient()
323
+ switch identifiersFrom(~id, ~props) {
324
+ | None =>
325
+ log.warn(
326
+ ~comp="AppSync_SourceApiAssociation_Retrying",
327
+ `delete: could not derive association identifiers from id "${id}"; treating as already gone`,
328
+ )
329
+ | Some(ids) =>
330
+ try {
331
+ await runWithRetry(() => client->sendDelete(newOf1(sdk.deleteCtor, ids)))
332
+ } catch {
333
+ | exn if exn->JsExn.fromException->Option.mapOr(false, isAlreadyGoneError) => ()
334
+ }
335
+ }
336
+ }
337
+
338
+ let isDefined: string => bool = %raw(`x => x !== undefined && x !== null`)
339
+
340
+ /** Identity fields force replace — AWS cannot re-point an association's merged
341
+ API or source API in place. Guard undefined olds (adopted classic state)
342
+ so a missing field never spuriously flags a replace. */
343
+ let diff_ = (_id: string, olds: createOuts, news: providerInputs): diffResult => {
344
+ let replaces =
345
+ [
346
+ isDefined(olds.mergedApiIdentifier) && olds.mergedApiIdentifier != news.mergedApiIdentifier
347
+ ? Some("mergedApiIdentifier")
348
+ : None,
349
+ isDefined(olds.sourceApiIdentifier) && olds.sourceApiIdentifier != news.sourceApiIdentifier
350
+ ? Some("sourceApiIdentifier")
351
+ : None,
352
+ isDefined(olds.mergeType) && olds.mergeType != news.mergeType ? Some("mergeType") : None,
353
+ ]->Array.filterMap(x => x)
354
+ // createBeforeDelete: a replace only ever fires on an identity change → a
355
+ // different (mergedApi, sourceApi) pair, so the new association can be made
356
+ // before the old is torn down without a duplicate-pair rejection.
357
+ {changes: replaces->Array.length > 0, replaces, deleteBeforeReplace: false}
358
+ }
359
+
360
+ /** Read live state for `pulumi refresh`: GetSourceApiAssociation. Missing
361
+ (association or merged API gone) → return `{}` so Pulumi drops it from state
362
+ and the next `up` recreates it; other errors propagate so refresh fails
363
+ loudly rather than silently discarding the resource. */
364
+ let read_ = async (id: string, props: createOuts): readResult => {
365
+ let sdk = await getSdk()
366
+ let client = await getClient()
367
+ switch identifiersFrom(~id, ~props) {
368
+ | None => ({}: readResult)
369
+ | Some(ids) =>
370
+ try {
371
+ let _ = await client->sendGet(newOf1(sdk.getCtor, ids))
372
+ {id, props}
373
+ } catch {
374
+ | exn if exn->JsExn.fromException->Option.mapOr(false, isAlreadyGoneError) =>
375
+ log.info(
376
+ ~comp="AppSync_SourceApiAssociation_Retrying",
377
+ `association ${ids.associationId} on ${ids.mergedApiIdentifier} missing in AppSync; reporting drift`,
378
+ )
379
+ ({}: readResult)
380
+ | exn =>
381
+ let jsExn = exn->JsExn.fromException
382
+ switch jsExn {
383
+ | Some(e) => jsThrow(e)
384
+ | None => throw(exn)
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ // Provider as a plain JS object (no Pulumi Output captures — all state flows
391
+ // through inputs / olds / news).
392
+ let provider = {
393
+ "create": create,
394
+ "update": update,
395
+ "delete": delete_,
396
+ "diff": diff_,
397
+ "read": read_,
398
+ }
399
+
400
+ // ── Pulumi dynamic resource binding ──────────────────────────────────────────
401
+
402
+ /** Output shape is identical to PulumiAws.AppSync.SourceApiAssociation.t so
403
+ `AppSync_MergedApi.associateSourceWithMergedArn` and `mergeStatusGateWith`
404
+ consume `.id` / `.arn` / `.associationId` unchanged. */
405
+ type t = PulumiAws.AppSync.SourceApiAssociation.t
406
+
407
+ type constructorProps = {
408
+ mergedApiIdentifier: Pulumi.Input.t<string>,
409
+ sourceApiIdentifier: Pulumi.Input.t<string>,
410
+ mergeType: Pulumi.Input.t<string>,
411
+ }
412
+
413
+ // pulumi.dynamic.Resource constructor: (provider, name, props, opts). Explicit
414
+ // /index.js path because @pulumi/pulumi/dynamic is a directory import not
415
+ // resolvable in ESM mode.
416
+ @module("@pulumi/pulumi/dynamic/index.js") @new
417
+ external _newResource: ('provider, string, 'props, Pulumi.CustomResourceOptions.t) => t = "Resource"
418
+
419
+ let make = (
420
+ ~name: string,
421
+ ~props: constructorProps,
422
+ ~opts: option<Pulumi.CustomResourceOptions.t>,
423
+ ): t => {
424
+ // Adopt the classic aws resource previously in state in-place (no
425
+ // delete+recreate → no merge blip on the shared merged endpoint).
426
+ let migrationAlias = Pulumi.Alias.make(
427
+ ~type_="aws:appsync/sourceApiAssociation:SourceApiAssociation",
428
+ ~name,
429
+ (),
430
+ )
431
+ let finalOpts: Pulumi.CustomResourceOptions.t = switch opts {
432
+ | Some(o) => {...o, aliases: [migrationAlias]}
433
+ | None => {aliases: [migrationAlias]}
434
+ }
435
+ _newResource(provider, name, props, finalOpts)
436
+ }