alchemy-effect 0.3.0 → 0.4.0
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/bin/alchemy-effect.js +539 -223
- package/bin/alchemy-effect.js.map +1 -1
- package/lib/apply.d.ts +4 -4
- package/lib/apply.d.ts.map +1 -1
- package/lib/apply.js +411 -131
- package/lib/apply.js.map +1 -1
- package/lib/aws/dynamodb/table.provider.d.ts.map +1 -1
- package/lib/aws/dynamodb/table.provider.js +1 -0
- package/lib/aws/dynamodb/table.provider.js.map +1 -1
- package/lib/aws/ec2/index.d.ts +8 -0
- package/lib/aws/ec2/index.d.ts.map +1 -1
- package/lib/aws/ec2/index.js +8 -0
- package/lib/aws/ec2/index.js.map +1 -1
- package/lib/aws/ec2/internet-gateway.d.ts +65 -0
- package/lib/aws/ec2/internet-gateway.d.ts.map +1 -0
- package/lib/aws/ec2/internet-gateway.js +4 -0
- package/lib/aws/ec2/internet-gateway.js.map +1 -0
- package/lib/aws/ec2/internet-gateway.provider.d.ts +6 -0
- package/lib/aws/ec2/internet-gateway.provider.d.ts.map +1 -0
- package/lib/aws/ec2/internet-gateway.provider.js +193 -0
- package/lib/aws/ec2/internet-gateway.provider.js.map +1 -0
- package/lib/aws/ec2/route-table-association.d.ts +63 -0
- package/lib/aws/ec2/route-table-association.d.ts.map +1 -0
- package/lib/aws/ec2/route-table-association.js +4 -0
- package/lib/aws/ec2/route-table-association.js.map +1 -0
- package/lib/aws/ec2/route-table-association.provider.d.ts +4 -0
- package/lib/aws/ec2/route-table-association.provider.d.ts.map +1 -0
- package/lib/aws/ec2/route-table-association.provider.js +121 -0
- package/lib/aws/ec2/route-table-association.provider.js.map +1 -0
- package/lib/aws/ec2/route-table.d.ts +159 -0
- package/lib/aws/ec2/route-table.d.ts.map +1 -0
- package/lib/aws/ec2/route-table.js +4 -0
- package/lib/aws/ec2/route-table.js.map +1 -0
- package/lib/aws/ec2/route-table.provider.d.ts +6 -0
- package/lib/aws/ec2/route-table.provider.d.ts.map +1 -0
- package/lib/aws/ec2/route-table.provider.js +213 -0
- package/lib/aws/ec2/route-table.provider.js.map +1 -0
- package/lib/aws/ec2/route.d.ts +155 -0
- package/lib/aws/ec2/route.d.ts.map +1 -0
- package/lib/aws/ec2/route.js +3 -0
- package/lib/aws/ec2/route.js.map +1 -0
- package/lib/aws/ec2/route.provider.d.ts +4 -0
- package/lib/aws/ec2/route.provider.d.ts.map +1 -0
- package/lib/aws/ec2/route.provider.js +166 -0
- package/lib/aws/ec2/route.provider.js.map +1 -0
- package/lib/aws/ec2/subnet.provider.d.ts.map +1 -1
- package/lib/aws/ec2/subnet.provider.js +1 -1
- package/lib/aws/ec2/subnet.provider.js.map +1 -1
- package/lib/aws/ec2/vpc.d.ts +1 -0
- package/lib/aws/ec2/vpc.d.ts.map +1 -1
- package/lib/aws/ec2/vpc.provider.d.ts +2 -2
- package/lib/aws/ec2/vpc.provider.d.ts.map +1 -1
- package/lib/aws/ec2/vpc.provider.js +38 -15
- package/lib/aws/ec2/vpc.provider.js.map +1 -1
- package/lib/aws/index.d.ts +2 -3
- package/lib/aws/index.d.ts.map +1 -1
- package/lib/aws/index.js +2 -1
- package/lib/aws/index.js.map +1 -1
- package/lib/aws/lambda/function.provider.d.ts +2 -2
- package/lib/aws/lambda/function.provider.d.ts.map +1 -1
- package/lib/aws/lambda/function.provider.js +21 -20
- package/lib/aws/lambda/function.provider.js.map +1 -1
- package/lib/aws/sqs/queue.provider.d.ts +2 -2
- package/lib/aws/sqs/queue.provider.d.ts.map +1 -1
- package/lib/aws/sqs/queue.provider.js +3 -2
- package/lib/aws/sqs/queue.provider.js.map +1 -1
- package/lib/cli/index.d.ts +178 -99
- package/lib/cli/index.d.ts.map +1 -1
- package/lib/cloudflare/kv/namespace.client.d.ts +1 -1
- package/lib/cloudflare/kv/namespace.provider.d.ts.map +1 -1
- package/lib/cloudflare/kv/namespace.provider.js +1 -0
- package/lib/cloudflare/kv/namespace.provider.js.map +1 -1
- package/lib/cloudflare/r2/bucket.provider.d.ts.map +1 -1
- package/lib/cloudflare/r2/bucket.provider.js +6 -1
- package/lib/cloudflare/r2/bucket.provider.js.map +1 -1
- package/lib/cloudflare/worker/worker.provider.d.ts +1 -1
- package/lib/cloudflare/worker/worker.provider.d.ts.map +1 -1
- package/lib/cloudflare/worker/worker.provider.js +6 -2
- package/lib/cloudflare/worker/worker.provider.js.map +1 -1
- package/lib/diff.d.ts +8 -6
- package/lib/diff.d.ts.map +1 -1
- package/lib/diff.js +13 -0
- package/lib/diff.js.map +1 -1
- package/lib/event.d.ts +1 -1
- package/lib/event.d.ts.map +1 -1
- package/lib/instance-id.d.ts +8 -0
- package/lib/instance-id.d.ts.map +1 -0
- package/lib/instance-id.js +12 -0
- package/lib/instance-id.js.map +1 -0
- package/lib/output.d.ts +4 -2
- package/lib/output.d.ts.map +1 -1
- package/lib/output.js +18 -4
- package/lib/output.js.map +1 -1
- package/lib/physical-name.d.ts +14 -1
- package/lib/physical-name.d.ts.map +1 -1
- package/lib/physical-name.js +41 -2
- package/lib/physical-name.js.map +1 -1
- package/lib/plan.d.ts +49 -42
- package/lib/plan.d.ts.map +1 -1
- package/lib/plan.js +359 -127
- package/lib/plan.js.map +1 -1
- package/lib/provider.d.ts +26 -9
- package/lib/provider.d.ts.map +1 -1
- package/lib/provider.js +9 -0
- package/lib/provider.js.map +1 -1
- package/lib/resource.d.ts +2 -2
- package/lib/resource.d.ts.map +1 -1
- package/lib/resource.js.map +1 -1
- package/lib/state.d.ts +86 -9
- package/lib/state.d.ts.map +1 -1
- package/lib/state.js +21 -18
- package/lib/state.js.map +1 -1
- package/lib/tags.d.ts +15 -0
- package/lib/tags.d.ts.map +1 -1
- package/lib/tags.js +27 -0
- package/lib/tags.js.map +1 -1
- package/lib/test.d.ts +2 -2
- package/lib/test.d.ts.map +1 -1
- package/lib/test.js +4 -4
- package/lib/test.js.map +1 -1
- package/lib/todo.d.ts +3 -0
- package/lib/todo.d.ts.map +1 -0
- package/lib/todo.js +3 -0
- package/lib/todo.js.map +1 -0
- package/lib/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/apply.ts +758 -374
- package/src/aws/dynamodb/table.provider.ts +1 -0
- package/src/aws/ec2/index.ts +8 -0
- package/src/aws/ec2/internet-gateway.provider.ts +316 -0
- package/src/aws/ec2/internet-gateway.ts +79 -0
- package/src/aws/ec2/route-table-association.provider.ts +214 -0
- package/src/aws/ec2/route-table-association.ts +82 -0
- package/src/aws/ec2/route-table.provider.ts +306 -0
- package/src/aws/ec2/route-table.ts +175 -0
- package/src/aws/ec2/route.provider.ts +213 -0
- package/src/aws/ec2/route.ts +192 -0
- package/src/aws/ec2/subnet.provider.ts +2 -2
- package/src/aws/ec2/vpc.provider.ts +43 -19
- package/src/aws/ec2/vpc.ts +2 -0
- package/src/aws/index.ts +4 -1
- package/src/aws/lambda/function.provider.ts +25 -23
- package/src/aws/sqs/queue.provider.ts +3 -2
- package/src/cloudflare/kv/namespace.provider.ts +1 -0
- package/src/cloudflare/r2/bucket.provider.ts +7 -1
- package/src/cloudflare/worker/worker.provider.ts +6 -2
- package/src/diff.ts +35 -17
- package/src/event.ts +6 -0
- package/src/instance-id.ts +16 -0
- package/src/output.ts +29 -5
- package/src/physical-name.ts +57 -2
- package/src/plan.ts +488 -197
- package/src/provider.ts +46 -9
- package/src/resource.ts +50 -4
- package/src/state.ts +150 -35
- package/src/tags.ts +31 -0
- package/src/test.ts +5 -5
- package/src/todo.ts +4 -0
package/src/plan.ts
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
import * as Context from "effect/Context";
|
|
2
|
-
import { App } from "./app.ts";
|
|
3
2
|
import * as Data from "effect/Data";
|
|
4
3
|
import * as Effect from "effect/Effect";
|
|
5
4
|
import { omit } from "effect/Struct";
|
|
5
|
+
import { App } from "./app.ts";
|
|
6
6
|
import type {
|
|
7
7
|
AnyBinding,
|
|
8
8
|
BindingDiffProps,
|
|
9
9
|
BindingService,
|
|
10
10
|
} from "./binding.ts";
|
|
11
11
|
import type { Capability } from "./capability.ts";
|
|
12
|
-
import type { Diff } from "./diff.ts";
|
|
12
|
+
import type { Diff, NoopDiff, UpdateDiff } from "./diff.ts";
|
|
13
13
|
import * as Output from "./output.ts";
|
|
14
14
|
import type { Instance } from "./policy.ts";
|
|
15
15
|
import type { Provider } from "./provider.ts";
|
|
16
|
-
import { type ProviderService } from "./provider.ts";
|
|
16
|
+
import { type ProviderService, getProviderByType } from "./provider.ts";
|
|
17
17
|
import type { AnyResource, Resource, ResourceTags } from "./resource.ts";
|
|
18
18
|
import { isService, type IService, type Service } from "./service.ts";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
type CreatedResourceState,
|
|
21
|
+
type CreatingResourceState,
|
|
22
|
+
type ReplacedResourceState,
|
|
23
|
+
type ReplacingResourceState,
|
|
24
|
+
type UpdatedResourceState,
|
|
25
|
+
type UpdatingReourceState,
|
|
26
|
+
State,
|
|
27
|
+
StateStoreError,
|
|
28
|
+
type ResourceState,
|
|
29
|
+
} from "./state.ts";
|
|
30
|
+
import { asEffect } from "./util.ts";
|
|
20
31
|
|
|
21
32
|
export type PlanError = never;
|
|
22
33
|
|
|
@@ -92,65 +103,55 @@ export type Apply<R extends Resource = AnyResource> =
|
|
|
92
103
|
| Replace<R>
|
|
93
104
|
| NoopUpdate<R>;
|
|
94
105
|
|
|
95
|
-
|
|
96
|
-
...node,
|
|
97
|
-
toString(): string {
|
|
98
|
-
return `${this.action.charAt(0).toUpperCase()}${this.action.slice(1)}(${this.resource})`;
|
|
99
|
-
},
|
|
100
|
-
[Symbol.toStringTag]() {
|
|
101
|
-
return this.toString();
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
export type Create<R extends Resource = AnyResource> = {
|
|
106
|
-
action: "create";
|
|
106
|
+
export interface BaseNode<R extends Resource = AnyResource> {
|
|
107
107
|
resource: R;
|
|
108
|
-
news: any;
|
|
109
108
|
provider: ProviderService<R>;
|
|
110
|
-
attributes: R["attr"];
|
|
111
109
|
bindings: BindNode[];
|
|
112
|
-
|
|
110
|
+
downstream: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface Create<R extends Resource = AnyResource> extends BaseNode<R> {
|
|
114
|
+
action: "create";
|
|
115
|
+
props: any;
|
|
116
|
+
state: CreatingResourceState | undefined;
|
|
117
|
+
}
|
|
113
118
|
|
|
114
|
-
export
|
|
119
|
+
export interface Update<R extends Resource = AnyResource> extends BaseNode<R> {
|
|
115
120
|
action: "update";
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
props: any;
|
|
122
|
+
state:
|
|
123
|
+
| CreatedResourceState
|
|
124
|
+
| UpdatedResourceState
|
|
125
|
+
| UpdatingReourceState
|
|
126
|
+
// the props can change after creating the replacement resource,
|
|
127
|
+
// so Apply needs to handle updates and then continue with cleaning up the replaced graph
|
|
128
|
+
| ReplacedResourceState;
|
|
129
|
+
}
|
|
124
130
|
|
|
125
|
-
export
|
|
131
|
+
export interface Delete<R extends Resource = AnyResource> extends BaseNode<R> {
|
|
126
132
|
action: "delete";
|
|
127
|
-
resource
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
provider: ProviderService<R>;
|
|
131
|
-
bindings: BindNode[];
|
|
132
|
-
attributes: R["attr"];
|
|
133
|
-
downstream: string[];
|
|
134
|
-
};
|
|
133
|
+
// a resource can be deleted no matter what state it's in
|
|
134
|
+
state: ResourceState;
|
|
135
|
+
}
|
|
135
136
|
|
|
136
|
-
export
|
|
137
|
+
export interface NoopUpdate<R extends Resource = AnyResource>
|
|
138
|
+
extends BaseNode<R> {
|
|
137
139
|
action: "noop";
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
bindings: BindNode[];
|
|
141
|
-
};
|
|
140
|
+
state: CreatedResourceState | UpdatedResourceState;
|
|
141
|
+
}
|
|
142
142
|
|
|
143
|
-
export
|
|
143
|
+
export interface Replace<R extends Resource = AnyResource> extends BaseNode<R> {
|
|
144
144
|
action: "replace";
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
145
|
+
props: any;
|
|
146
|
+
deleteFirst: boolean;
|
|
147
|
+
state:
|
|
148
|
+
| CreatingResourceState
|
|
149
|
+
| CreatedResourceState
|
|
150
|
+
| UpdatingReourceState
|
|
151
|
+
| UpdatedResourceState
|
|
152
|
+
| ReplacingResourceState
|
|
153
|
+
| ReplacedResourceState;
|
|
154
|
+
}
|
|
154
155
|
|
|
155
156
|
export type ResourceGraph<Resources extends Service | Resource> = ToGraph<
|
|
156
157
|
TraverseResources<Resources>
|
|
@@ -236,7 +237,7 @@ export type DerivePlan<Resources extends Service | Resource> = {
|
|
|
236
237
|
|
|
237
238
|
export type IPlan = {
|
|
238
239
|
resources: {
|
|
239
|
-
[id in string]:
|
|
240
|
+
[id in string]: Apply<any>;
|
|
240
241
|
};
|
|
241
242
|
deletions: {
|
|
242
243
|
[id in string]?: Delete<Resource>;
|
|
@@ -245,16 +246,39 @@ export type IPlan = {
|
|
|
245
246
|
|
|
246
247
|
export type Plan<Resources extends Service | Resource> = Effect.Effect<
|
|
247
248
|
DerivePlan<Resources>,
|
|
248
|
-
|
|
249
|
+
| CannotReplacePartiallyReplacedResource
|
|
250
|
+
| DeleteResourceHasDownstreamDependencies,
|
|
249
251
|
Providers<Resources> | State
|
|
250
252
|
>;
|
|
251
253
|
|
|
252
254
|
export const plan = <const Resources extends (Service | Resource)[]>(
|
|
253
|
-
...
|
|
255
|
+
..._resources: Resources
|
|
254
256
|
): Plan<Instance<Resources[number]>> =>
|
|
255
257
|
Effect.gen(function* () {
|
|
256
258
|
const state = yield* State;
|
|
257
259
|
|
|
260
|
+
const findResources = (
|
|
261
|
+
resource: Service | Resource,
|
|
262
|
+
visited: Set<string>,
|
|
263
|
+
): (Service | Resource)[] => {
|
|
264
|
+
if (visited.has(resource.id)) {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
visited.add(resource.id);
|
|
268
|
+
const upstream = Object.values(Output.upstreamAny(resource.props)) as (
|
|
269
|
+
| Service
|
|
270
|
+
| Resource
|
|
271
|
+
)[];
|
|
272
|
+
return [
|
|
273
|
+
resource,
|
|
274
|
+
...upstream,
|
|
275
|
+
...upstream.flatMap((r) => findResources(r, visited)),
|
|
276
|
+
];
|
|
277
|
+
};
|
|
278
|
+
const resources = _resources
|
|
279
|
+
.flatMap((r) => findResources(r, new Set()))
|
|
280
|
+
.filter((r, i, arr) => arr.findIndex((r2) => r2.id === r.id) === i);
|
|
281
|
+
|
|
258
282
|
// TODO(sam): rename terminology to Stack
|
|
259
283
|
const app = yield* App;
|
|
260
284
|
|
|
@@ -262,32 +286,11 @@ export const plan = <const Resources extends (Service | Resource)[]>(
|
|
|
262
286
|
stack: app.name,
|
|
263
287
|
stage: app.stage,
|
|
264
288
|
});
|
|
265
|
-
const
|
|
289
|
+
const oldResources = yield* Effect.all(
|
|
266
290
|
resourceIds.map((id) =>
|
|
267
291
|
state.get({ stack: app.name, stage: app.stage, resourceId: id }),
|
|
268
292
|
),
|
|
269
293
|
);
|
|
270
|
-
// map of resource ID -> its downstream dependencies (resources that depend on it)
|
|
271
|
-
const downstream = resourcesState
|
|
272
|
-
.filter(
|
|
273
|
-
(
|
|
274
|
-
resource,
|
|
275
|
-
): resource is ResourceState & {
|
|
276
|
-
bindings: BindNode[];
|
|
277
|
-
} => !!resource?.bindings,
|
|
278
|
-
)
|
|
279
|
-
.flatMap((resource) =>
|
|
280
|
-
resource.bindings.flatMap(({ binding }) => [
|
|
281
|
-
[binding.capability.resource.id, binding.capability.resource],
|
|
282
|
-
]),
|
|
283
|
-
)
|
|
284
|
-
.reduce(
|
|
285
|
-
(acc, [id, resourceId]) => ({
|
|
286
|
-
...acc,
|
|
287
|
-
[id]: [...(acc[id] ?? []), resourceId],
|
|
288
|
-
}),
|
|
289
|
-
{} as Record<string, string[]>,
|
|
290
|
-
);
|
|
291
294
|
|
|
292
295
|
type ResolveEffect<T> = Effect.Effect<T, ResolveErr, ResolveReq>;
|
|
293
296
|
type ResolveErr = StateStoreError;
|
|
@@ -301,9 +304,12 @@ export const plan = <const Resources extends (Service | Resource)[]>(
|
|
|
301
304
|
|
|
302
305
|
const resolvedResources: Record<
|
|
303
306
|
string,
|
|
304
|
-
ResolveEffect<
|
|
305
|
-
|
|
306
|
-
|
|
307
|
+
ResolveEffect<
|
|
308
|
+
| {
|
|
309
|
+
[attr in string]: any;
|
|
310
|
+
}
|
|
311
|
+
| undefined
|
|
312
|
+
>
|
|
307
313
|
> = {};
|
|
308
314
|
|
|
309
315
|
const resolveResource = (
|
|
@@ -324,41 +330,71 @@ export const plan = <const Resources extends (Service | Resource)[]>(
|
|
|
324
330
|
resourceId: resource.id,
|
|
325
331
|
});
|
|
326
332
|
|
|
327
|
-
if (!oldState) {
|
|
333
|
+
if (!oldState || oldState.status === "creating") {
|
|
328
334
|
return resourceExpr;
|
|
329
335
|
}
|
|
330
336
|
|
|
337
|
+
const oldProps =
|
|
338
|
+
oldState.status === "created" ||
|
|
339
|
+
oldState.status === "updated" ||
|
|
340
|
+
oldState.status === "replaced"
|
|
341
|
+
? // if we're in a stable state, then just use the props
|
|
342
|
+
oldState.props
|
|
343
|
+
: // if we failed to update or replace, compare with the last known stable props
|
|
344
|
+
oldState.status === "updating" ||
|
|
345
|
+
oldState.status === "replacing"
|
|
346
|
+
? oldState.old.props
|
|
347
|
+
: // TODO(sam): it kinda doesn't make sense to diff with a "deleting" state
|
|
348
|
+
oldState.props;
|
|
349
|
+
|
|
331
350
|
const diff = yield* provider.diff
|
|
332
351
|
? provider.diff({
|
|
333
352
|
id: resource.id,
|
|
334
|
-
olds:
|
|
353
|
+
olds: oldProps,
|
|
354
|
+
instanceId: oldState.instanceId,
|
|
335
355
|
news: props,
|
|
336
|
-
output: oldState.
|
|
356
|
+
output: oldState.attr,
|
|
337
357
|
})
|
|
338
358
|
: Effect.succeed(undefined);
|
|
339
359
|
|
|
360
|
+
const stables: string[] = [
|
|
361
|
+
...(provider.stables ?? []),
|
|
362
|
+
...(diff?.stables ?? []),
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
const withStables = (output: any) =>
|
|
366
|
+
stables.length > 0
|
|
367
|
+
? new Output.ResourceExpr(
|
|
368
|
+
resourceExpr.src,
|
|
369
|
+
Object.fromEntries(
|
|
370
|
+
stables.map((stable) => [stable, output?.[stable]]),
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
: // if there are no stable properties, treat every property as changed
|
|
374
|
+
resourceExpr;
|
|
375
|
+
|
|
340
376
|
if (diff == null) {
|
|
341
|
-
if (arePropsChanged(
|
|
377
|
+
if (arePropsChanged(oldProps, props)) {
|
|
342
378
|
// the props have changed but the provider did not provide any hints as to what is stable
|
|
343
379
|
// so we must assume everything has changed
|
|
344
|
-
return
|
|
380
|
+
return withStables(oldState?.attr);
|
|
345
381
|
}
|
|
346
382
|
} else if (diff.action === "update") {
|
|
347
|
-
|
|
348
|
-
if (diff.stables) {
|
|
349
|
-
return new Output.ResourceExpr(
|
|
350
|
-
resourceExpr.src,
|
|
351
|
-
Object.fromEntries(
|
|
352
|
-
diff.stables.map((stable) => [stable, output?.[stable]]),
|
|
353
|
-
),
|
|
354
|
-
);
|
|
355
|
-
} else {
|
|
356
|
-
// if there are no stable properties, treat every property as changed
|
|
357
|
-
return resourceExpr;
|
|
358
|
-
}
|
|
383
|
+
return withStables(oldState?.attr);
|
|
359
384
|
} else if (diff.action === "replace") {
|
|
385
|
+
return resourceExpr;
|
|
386
|
+
}
|
|
387
|
+
if (
|
|
388
|
+
oldState.status === "created" ||
|
|
389
|
+
oldState.status === "updated" ||
|
|
390
|
+
oldState.status === "replaced"
|
|
391
|
+
) {
|
|
392
|
+
// we can safely return the attributes if we know they have stabilized
|
|
393
|
+
return oldState?.attr;
|
|
394
|
+
} else {
|
|
395
|
+
// we must assume the resource doesn't exist if it hasn't stabilized
|
|
396
|
+
return resourceExpr;
|
|
360
397
|
}
|
|
361
|
-
return oldState?.output;
|
|
362
398
|
}),
|
|
363
399
|
));
|
|
364
400
|
});
|
|
@@ -392,36 +428,49 @@ export const plan = <const Resources extends (Service | Resource)[]>(
|
|
|
392
428
|
return upstream?.[expr.identifier];
|
|
393
429
|
} else if (Output.isApplyExpr(expr)) {
|
|
394
430
|
const upstream = yield* resolveOutput(expr.expr);
|
|
395
|
-
return Output.
|
|
431
|
+
return Output.hasOutputs(upstream) ? expr : expr.f(upstream);
|
|
396
432
|
} else if (Output.isEffectExpr(expr)) {
|
|
397
433
|
const upstream = yield* resolveOutput(expr.expr);
|
|
398
|
-
return Output.
|
|
434
|
+
return Output.hasOutputs(upstream) ? expr : yield* expr.f(upstream);
|
|
399
435
|
} else if (Output.isAllExpr(expr)) {
|
|
400
436
|
return yield* Effect.all(expr.outs.map(resolveOutput));
|
|
401
437
|
}
|
|
402
438
|
return yield* Effect.die(new Error("Not implemented yet"));
|
|
403
439
|
});
|
|
404
440
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
)
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
...Object.values(
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
441
|
+
// map of resource ID -> its downstream dependencies (resources that depend on it)
|
|
442
|
+
const oldDownstreamDependencies: {
|
|
443
|
+
[resourceId: string]: string[];
|
|
444
|
+
} = Object.fromEntries(
|
|
445
|
+
oldResources
|
|
446
|
+
.filter((resource) => !!resource)
|
|
447
|
+
.map((resource) => [resource.logicalId, resource.downstream]),
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const newUpstreamDependencies: {
|
|
451
|
+
[resourceId: string]: string[];
|
|
452
|
+
} = Object.fromEntries(
|
|
453
|
+
resources.map((resource) => [
|
|
454
|
+
resource.id,
|
|
455
|
+
[
|
|
456
|
+
...Object.values(Output.upstreamAny(resource.props)).map((r) => r.id),
|
|
457
|
+
...(isService(resource)
|
|
458
|
+
? resource.props.bindings.capabilities.map((cap) => cap.resource.id)
|
|
459
|
+
: []),
|
|
460
|
+
],
|
|
461
|
+
]),
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const newDownstreamDependencies: {
|
|
465
|
+
[resourceId: string]: string[];
|
|
466
|
+
} = Object.fromEntries(
|
|
467
|
+
resources.map((resource) => [
|
|
468
|
+
resource.id,
|
|
469
|
+
Object.entries(newUpstreamDependencies)
|
|
470
|
+
.filter(([_, downstream]) => downstream.includes(resource.id))
|
|
471
|
+
.map(([id]) => id),
|
|
472
|
+
]),
|
|
473
|
+
);
|
|
425
474
|
|
|
426
475
|
const resourceGraph = Object.fromEntries(
|
|
427
476
|
(yield* Effect.all(
|
|
@@ -432,7 +481,7 @@ export const plan = <const Resources extends (Service | Resource)[]>(
|
|
|
432
481
|
(cap: Capability) => cap.resource as Resource,
|
|
433
482
|
)
|
|
434
483
|
: []),
|
|
435
|
-
...Object.values(
|
|
484
|
+
...Object.values(Output.upstreamAny(resource.props)),
|
|
436
485
|
resource,
|
|
437
486
|
])
|
|
438
487
|
.filter(
|
|
@@ -453,6 +502,8 @@ export const plan = <const Resources extends (Service | Resource)[]>(
|
|
|
453
502
|
});
|
|
454
503
|
const provider = yield* resource.provider.tag;
|
|
455
504
|
|
|
505
|
+
const downstream = newDownstreamDependencies[id] ?? [];
|
|
506
|
+
|
|
456
507
|
const bindings = isService(node)
|
|
457
508
|
? yield* diffBindings({
|
|
458
509
|
oldState,
|
|
@@ -464,79 +515,207 @@ export const plan = <const Resources extends (Service | Resource)[]>(
|
|
|
464
515
|
target: {
|
|
465
516
|
id: node.id,
|
|
466
517
|
props: node.props,
|
|
467
|
-
|
|
518
|
+
// TODO(sam): pick the right ones based on old status
|
|
519
|
+
oldAttr: oldState?.attr,
|
|
468
520
|
oldProps: oldState?.props,
|
|
469
521
|
},
|
|
470
522
|
})
|
|
471
523
|
: []; // TODO(sam): return undefined instead of empty array
|
|
472
524
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
525
|
+
const Node = <T extends Apply>(
|
|
526
|
+
node: Omit<
|
|
527
|
+
T,
|
|
528
|
+
"provider" | "resource" | "bindings" | "downstream"
|
|
529
|
+
>,
|
|
530
|
+
) =>
|
|
531
|
+
({
|
|
532
|
+
...node,
|
|
477
533
|
provider,
|
|
478
534
|
resource,
|
|
479
535
|
bindings,
|
|
480
|
-
|
|
481
|
-
|
|
536
|
+
downstream,
|
|
537
|
+
}) as any as T;
|
|
538
|
+
|
|
539
|
+
// handle empty and intermediate (non-final) states:
|
|
540
|
+
if (oldState === undefined) {
|
|
541
|
+
return Node<Create<Resource>>({
|
|
542
|
+
action: "create",
|
|
543
|
+
props: news,
|
|
544
|
+
state: oldState,
|
|
482
545
|
});
|
|
483
546
|
}
|
|
484
547
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
548
|
+
// TODO(sam): is this correct for all possible states a resource can be in?
|
|
549
|
+
const oldProps = oldState.props;
|
|
550
|
+
|
|
551
|
+
const diff = yield* asEffect(
|
|
552
|
+
provider.diff
|
|
553
|
+
? provider.diff({
|
|
488
554
|
id,
|
|
489
|
-
olds:
|
|
555
|
+
olds: oldProps,
|
|
556
|
+
instanceId: oldState.instanceId,
|
|
557
|
+
output: oldState.attr,
|
|
490
558
|
news,
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
559
|
+
})
|
|
560
|
+
: undefined,
|
|
561
|
+
).pipe(
|
|
562
|
+
Effect.map(
|
|
563
|
+
(diff) =>
|
|
564
|
+
diff ??
|
|
565
|
+
({
|
|
566
|
+
action: arePropsChanged(oldProps, news)
|
|
567
|
+
? "update"
|
|
568
|
+
: "noop",
|
|
569
|
+
} as UpdateDiff | NoopDiff),
|
|
570
|
+
),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
if (oldState.status === "creating") {
|
|
574
|
+
if (diff.action === "noop") {
|
|
575
|
+
// we're in the creating state and props are un-changed
|
|
576
|
+
// let's just continue where we left off
|
|
577
|
+
return Node<Create<Resource>>({
|
|
578
|
+
action: "create",
|
|
579
|
+
props: news,
|
|
580
|
+
state: oldState,
|
|
581
|
+
});
|
|
582
|
+
} else if (diff.action === "update") {
|
|
583
|
+
// props have changed in a way that is updatable
|
|
584
|
+
// again, just continue with the create
|
|
585
|
+
// TODO(sam): should we maybe try an update instead?
|
|
586
|
+
return Node<Create<Resource>>({
|
|
587
|
+
action: "create",
|
|
588
|
+
props: news,
|
|
589
|
+
state: oldState,
|
|
590
|
+
});
|
|
591
|
+
} else {
|
|
592
|
+
// props have changed in an incompatible way
|
|
593
|
+
// because it's possible that an un-updatable resource has already been created
|
|
594
|
+
// we must use a replace step to create a new one and delete the potential old one
|
|
595
|
+
return Node<Replace<Resource>>({
|
|
596
|
+
action: "replace",
|
|
597
|
+
props: news,
|
|
598
|
+
deleteFirst: diff.deleteFirst ?? false,
|
|
599
|
+
state: oldState,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
} else if (oldState.status === "updating") {
|
|
603
|
+
// we started to update a resource but did not complete
|
|
604
|
+
if (diff.action === "update" || diff.action === "noop") {
|
|
605
|
+
return Node<Update<Resource>>({
|
|
606
|
+
action: "update",
|
|
607
|
+
props: news,
|
|
608
|
+
state: oldState,
|
|
609
|
+
});
|
|
610
|
+
} else {
|
|
611
|
+
// we started to update a resource but now believe we should replace it
|
|
612
|
+
return Node<Replace<Resource>>({
|
|
613
|
+
action: "replace",
|
|
614
|
+
deleteFirst: diff.deleteFirst ?? false,
|
|
615
|
+
props: news,
|
|
616
|
+
// TODO(sam): can Apply handle replacements when the oldState is UpdatingResourceState?
|
|
617
|
+
// -> or is there we do a provider.read to try and reconcile back to UpdatedResourceState?
|
|
618
|
+
state: oldState,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
} else if (oldState.status === "replacing") {
|
|
622
|
+
// resource replacement started, but the replacement may or may not have been created
|
|
623
|
+
if (diff.action === "noop") {
|
|
624
|
+
// this is the stable case - noop means just continue with the replacement
|
|
625
|
+
return Node<Replace<Resource>>({
|
|
626
|
+
action: "replace",
|
|
627
|
+
deleteFirst: oldState.deleteFirst,
|
|
628
|
+
props: news,
|
|
629
|
+
state: oldState,
|
|
630
|
+
});
|
|
631
|
+
} else if (diff.action === "update") {
|
|
632
|
+
// potential problem here - the props have changed since we tried to replace,
|
|
633
|
+
// but not enough to trigger another replacement. the resource provider should
|
|
634
|
+
// be designed as idempotent to converge to the right state when creating the new resource
|
|
635
|
+
// the newly generated instanceId is intended to assist with this
|
|
636
|
+
return Node<Replace<Resource>>({
|
|
637
|
+
action: "replace",
|
|
638
|
+
deleteFirst: oldState.deleteFirst,
|
|
639
|
+
props: news,
|
|
640
|
+
state: oldState,
|
|
641
|
+
});
|
|
642
|
+
} else {
|
|
643
|
+
// ah shit, so we tried to replace the resource and then crashed
|
|
644
|
+
// now the props have changed again in such a way that the (maybe, maybe not)
|
|
645
|
+
// created resource should also be replaced
|
|
496
646
|
|
|
497
|
-
|
|
647
|
+
// TODO(sam): what should we do?
|
|
648
|
+
// 1. trigger a deletion of the potentially created resource
|
|
649
|
+
// 2. expect the resource provider to handle it idempotently?
|
|
650
|
+
// -> i don't think this case is fair to put on the resource provider
|
|
651
|
+
// because if the resource was created, it's in a state that can't be updated
|
|
652
|
+
return yield* Effect.fail(
|
|
653
|
+
new CannotReplacePartiallyReplacedResource(id),
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
} else if (oldState.status === "replaced") {
|
|
657
|
+
// replacement has been created but we're not done cleaning up the old state
|
|
658
|
+
if (diff.action === "noop") {
|
|
659
|
+
// this is the stable case - noop means just continue cleaning up the replacement
|
|
660
|
+
return Node<Replace<Resource>>({
|
|
661
|
+
action: "replace",
|
|
662
|
+
deleteFirst: oldState.deleteFirst,
|
|
663
|
+
props: news,
|
|
664
|
+
state: oldState,
|
|
665
|
+
});
|
|
666
|
+
} else if (diff.action === "update") {
|
|
667
|
+
// the replacement has been created but now also needs to be updated
|
|
668
|
+
// the resource provider should:
|
|
669
|
+
// 1. Update the newly created replacement resource
|
|
670
|
+
// 2. Then proceed as normal to delete the replaced resources (after all downstream references are updated)
|
|
671
|
+
return Node<Update<Resource>>({
|
|
672
|
+
action: "update",
|
|
673
|
+
props: news,
|
|
674
|
+
state: oldState,
|
|
675
|
+
});
|
|
676
|
+
} else {
|
|
677
|
+
// the replacement has been created but now it needs to be replaced
|
|
678
|
+
// this is the worst-case scenario because downstream resources
|
|
679
|
+
// could have been been updated to point to the replaced resources
|
|
680
|
+
return yield* Effect.fail(
|
|
681
|
+
new CannotReplacePartiallyReplacedResource(id),
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
} else if (oldState.status === "deleting") {
|
|
685
|
+
if (diff.action === "noop" || diff.action === "update") {
|
|
686
|
+
// we're in a partially deleted state, it is unclear whether it was or was not deleted
|
|
687
|
+
// it should be safe to re-create it with the same instanceId?
|
|
688
|
+
return Node<Create<Resource>>({
|
|
689
|
+
action: "create",
|
|
690
|
+
props: news,
|
|
691
|
+
state: {
|
|
692
|
+
...oldState,
|
|
693
|
+
status: "creating",
|
|
694
|
+
props: news,
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
} else {
|
|
698
|
+
return yield* Effect.fail(
|
|
699
|
+
new CannotReplacePartiallyReplacedResource(id),
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
} else if (diff.action === "update") {
|
|
498
703
|
return Node<Update<Resource>>({
|
|
499
704
|
action: "update",
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
output: oldState.output,
|
|
503
|
-
provider,
|
|
504
|
-
resource,
|
|
505
|
-
bindings,
|
|
506
|
-
// phantom
|
|
507
|
-
attributes: undefined!,
|
|
705
|
+
props: news,
|
|
706
|
+
state: oldState,
|
|
508
707
|
});
|
|
509
|
-
} else if (diff
|
|
708
|
+
} else if (diff.action === "replace") {
|
|
510
709
|
return Node<Replace<Resource>>({
|
|
511
710
|
action: "replace",
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
provider,
|
|
516
|
-
resource,
|
|
517
|
-
bindings,
|
|
518
|
-
// phantom
|
|
519
|
-
attributes: undefined!,
|
|
520
|
-
});
|
|
521
|
-
} else if (diff?.action === "update") {
|
|
522
|
-
return Node<Update<Resource>>({
|
|
523
|
-
action: "update",
|
|
524
|
-
olds: oldState.props,
|
|
525
|
-
news,
|
|
526
|
-
output: oldState.output,
|
|
527
|
-
provider,
|
|
528
|
-
resource,
|
|
529
|
-
bindings,
|
|
530
|
-
// phantom
|
|
531
|
-
attributes: undefined!,
|
|
711
|
+
props: news,
|
|
712
|
+
state: oldState,
|
|
713
|
+
deleteFirst: diff?.deleteFirst ?? false,
|
|
532
714
|
});
|
|
533
715
|
} else {
|
|
534
716
|
return Node<NoopUpdate<Resource>>({
|
|
535
717
|
action: "noop",
|
|
536
|
-
|
|
537
|
-
bindings,
|
|
538
|
-
// phantom
|
|
539
|
-
attributes: undefined!,
|
|
718
|
+
state: oldState,
|
|
540
719
|
});
|
|
541
720
|
}
|
|
542
721
|
}),
|
|
@@ -556,33 +735,24 @@ export const plan = <const Resources extends (Service | Resource)[]>(
|
|
|
556
735
|
stage: app.stage,
|
|
557
736
|
resourceId: id,
|
|
558
737
|
});
|
|
559
|
-
const context = yield* Effect.context<never>();
|
|
560
738
|
if (oldState) {
|
|
561
|
-
const provider
|
|
562
|
-
oldState?.type,
|
|
563
|
-
);
|
|
564
|
-
if (!provider) {
|
|
565
|
-
yield* Effect.die(
|
|
566
|
-
new Error(`Provider not found for ${oldState?.type}`),
|
|
567
|
-
);
|
|
568
|
-
}
|
|
739
|
+
const provider = yield* getProviderByType(oldState.resourceType);
|
|
569
740
|
return [
|
|
570
741
|
id,
|
|
571
742
|
{
|
|
572
743
|
action: "delete",
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
provider,
|
|
576
|
-
attributes: oldState?.output,
|
|
577
|
-
// TODO(sam): Support Detach Bindings
|
|
744
|
+
state: oldState,
|
|
745
|
+
// // TODO(sam): Support Detach Bindings
|
|
578
746
|
bindings: [],
|
|
747
|
+
provider,
|
|
579
748
|
resource: {
|
|
580
749
|
id: id,
|
|
581
|
-
type: oldState.
|
|
582
|
-
attr: oldState.
|
|
750
|
+
type: oldState.resourceType,
|
|
751
|
+
attr: oldState.attr,
|
|
583
752
|
props: oldState.props,
|
|
584
753
|
} as Resource,
|
|
585
|
-
|
|
754
|
+
// TODO(sam): is it enough to just pass through oldState?
|
|
755
|
+
downstream: oldDownstreamDependencies[id] ?? [],
|
|
586
756
|
} satisfies Delete<Resource>,
|
|
587
757
|
] as const;
|
|
588
758
|
}
|
|
@@ -592,7 +762,7 @@ export const plan = <const Resources extends (Service | Resource)[]>(
|
|
|
592
762
|
);
|
|
593
763
|
|
|
594
764
|
for (const [resourceId, deletion] of Object.entries(deletions)) {
|
|
595
|
-
const dependencies = deletion.downstream.filter(
|
|
765
|
+
const dependencies = deletion.state.downstream.filter(
|
|
596
766
|
(d) => d in resourceGraph,
|
|
597
767
|
);
|
|
598
768
|
if (dependencies.length > 0) {
|
|
@@ -612,7 +782,24 @@ export const plan = <const Resources extends (Service | Resource)[]>(
|
|
|
612
782
|
} satisfies IPlan as IPlan;
|
|
613
783
|
}) as any;
|
|
614
784
|
|
|
615
|
-
class
|
|
785
|
+
export class CannotReplacePartiallyReplacedResource extends Data.TaggedError(
|
|
786
|
+
"CannotReplacePartiallyReplacedResource",
|
|
787
|
+
)<{
|
|
788
|
+
message: string;
|
|
789
|
+
logicalId: string;
|
|
790
|
+
}> {
|
|
791
|
+
constructor(logicalId: string) {
|
|
792
|
+
super({
|
|
793
|
+
message:
|
|
794
|
+
`Resource '${logicalId}' did not finish being replaced in a previous deployment ` +
|
|
795
|
+
`and is expected to be replaced again in this deployment. ` +
|
|
796
|
+
`You should revert its properties and try again after a successful deployment.`,
|
|
797
|
+
logicalId,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export class DeleteResourceHasDownstreamDependencies extends Data.TaggedError(
|
|
616
803
|
"DeleteResourceHasDownstreamDependencies",
|
|
617
804
|
)<{
|
|
618
805
|
message: string;
|
|
@@ -621,12 +808,13 @@ class DeleteResourceHasDownstreamDependencies extends Data.TaggedError(
|
|
|
621
808
|
}> {}
|
|
622
809
|
|
|
623
810
|
const arePropsChanged = <R extends Resource>(
|
|
624
|
-
|
|
811
|
+
oldProps: R["props"] | undefined,
|
|
625
812
|
newProps: R["props"],
|
|
626
813
|
) => {
|
|
627
814
|
return (
|
|
628
|
-
|
|
629
|
-
JSON.stringify(omit((
|
|
815
|
+
Output.hasOutputs(newProps) ||
|
|
816
|
+
JSON.stringify(omit((oldProps ?? {}) as any, "bindings")) !==
|
|
817
|
+
JSON.stringify(omit((newProps ?? {}) as any, "bindings"))
|
|
630
818
|
);
|
|
631
819
|
};
|
|
632
820
|
|
|
@@ -641,9 +829,9 @@ const diffBindings = Effect.fn(function* ({
|
|
|
641
829
|
}) {
|
|
642
830
|
// const actions: BindNode[] = [];
|
|
643
831
|
const oldBindings = oldState?.bindings;
|
|
644
|
-
const oldSids = new Set(
|
|
645
|
-
|
|
646
|
-
);
|
|
832
|
+
// const oldSids = new Set(
|
|
833
|
+
// oldBindings?.map(({ binding }) => binding.capability.sid),
|
|
834
|
+
// );
|
|
647
835
|
|
|
648
836
|
const diffBinding: (
|
|
649
837
|
binding: AnyBinding,
|
|
@@ -747,7 +935,7 @@ const isBindingDiff = Effect.fn(function* ({
|
|
|
747
935
|
id: oldCap.resource.id,
|
|
748
936
|
props: newCap.resource.props,
|
|
749
937
|
oldProps: oldState?.props,
|
|
750
|
-
oldAttr: oldState?.
|
|
938
|
+
oldAttr: oldState?.attr,
|
|
751
939
|
},
|
|
752
940
|
props: newBinding.props,
|
|
753
941
|
attr: oldBinding.attr,
|
|
@@ -769,3 +957,106 @@ const isBindingDiff = Effect.fn(function* ({
|
|
|
769
957
|
});
|
|
770
958
|
// TODO(sam): compare props
|
|
771
959
|
// oldBinding.props !== newBinding.props;
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Print a plan in a human-readable format that shows the graph topology.
|
|
963
|
+
*/
|
|
964
|
+
export const printPlan = (plan: IPlan): string => {
|
|
965
|
+
const lines: string[] = [];
|
|
966
|
+
const allNodes = { ...plan.resources, ...plan.deletions };
|
|
967
|
+
|
|
968
|
+
// Build reverse mapping: upstream -> downstream
|
|
969
|
+
const upstreamMap: Record<string, string[]> = {};
|
|
970
|
+
for (const [id] of Object.entries(allNodes)) {
|
|
971
|
+
upstreamMap[id] = [];
|
|
972
|
+
}
|
|
973
|
+
for (const [id, node] of Object.entries(allNodes)) {
|
|
974
|
+
if (!node) continue;
|
|
975
|
+
for (const downstreamId of node.state?.downstream ?? []) {
|
|
976
|
+
if (upstreamMap[downstreamId]) {
|
|
977
|
+
upstreamMap[downstreamId].push(id);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Action symbols
|
|
983
|
+
const actionSymbol = (action: string) => {
|
|
984
|
+
switch (action) {
|
|
985
|
+
case "create":
|
|
986
|
+
return "+";
|
|
987
|
+
case "update":
|
|
988
|
+
return "~";
|
|
989
|
+
case "delete":
|
|
990
|
+
return "-";
|
|
991
|
+
case "replace":
|
|
992
|
+
return "±";
|
|
993
|
+
case "noop":
|
|
994
|
+
return "=";
|
|
995
|
+
default:
|
|
996
|
+
return "?";
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
// Print header
|
|
1001
|
+
lines.push(
|
|
1002
|
+
"╔════════════════════════════════════════════════════════════════╗",
|
|
1003
|
+
);
|
|
1004
|
+
lines.push(
|
|
1005
|
+
"║ PLAN ║",
|
|
1006
|
+
);
|
|
1007
|
+
lines.push(
|
|
1008
|
+
"╠════════════════════════════════════════════════════════════════╣",
|
|
1009
|
+
);
|
|
1010
|
+
lines.push(
|
|
1011
|
+
"║ Legend: + create, ~ update, - delete, ± replace, = noop ║",
|
|
1012
|
+
);
|
|
1013
|
+
lines.push(
|
|
1014
|
+
"╚════════════════════════════════════════════════════════════════╝",
|
|
1015
|
+
);
|
|
1016
|
+
lines.push("");
|
|
1017
|
+
|
|
1018
|
+
// Print resources section
|
|
1019
|
+
lines.push(
|
|
1020
|
+
"┌─ Resources ────────────────────────────────────────────────────┐",
|
|
1021
|
+
);
|
|
1022
|
+
const resourceIds = Object.keys(plan.resources).sort();
|
|
1023
|
+
for (const id of resourceIds) {
|
|
1024
|
+
const node = plan.resources[id];
|
|
1025
|
+
const symbol = actionSymbol(node.action);
|
|
1026
|
+
const type = node.resource?.type ?? "unknown";
|
|
1027
|
+
const downstream = node.state?.downstream?.length
|
|
1028
|
+
? ` → [${node.state?.downstream.join(", ")}]`
|
|
1029
|
+
: "";
|
|
1030
|
+
lines.push(`│ [${symbol}] ${id} (${type})${downstream}`);
|
|
1031
|
+
}
|
|
1032
|
+
if (resourceIds.length === 0) {
|
|
1033
|
+
lines.push("│ (none)");
|
|
1034
|
+
}
|
|
1035
|
+
lines.push(
|
|
1036
|
+
"└────────────────────────────────────────────────────────────────┘",
|
|
1037
|
+
);
|
|
1038
|
+
lines.push("");
|
|
1039
|
+
|
|
1040
|
+
// Print deletions section
|
|
1041
|
+
lines.push(
|
|
1042
|
+
"┌─ Deletions ────────────────────────────────────────────────────┐",
|
|
1043
|
+
);
|
|
1044
|
+
const deletionIds = Object.keys(plan.deletions).sort();
|
|
1045
|
+
for (const id of deletionIds) {
|
|
1046
|
+
const node = plan.deletions[id]!;
|
|
1047
|
+
const type = node.resource?.type ?? "unknown";
|
|
1048
|
+
const downstream = node.state.downstream?.length
|
|
1049
|
+
? ` → [${node.state.downstream.join(", ")}]`
|
|
1050
|
+
: "";
|
|
1051
|
+
lines.push(`│ [-] ${id} (${type})${downstream}`);
|
|
1052
|
+
}
|
|
1053
|
+
if (deletionIds.length === 0) {
|
|
1054
|
+
lines.push("│ (none)");
|
|
1055
|
+
}
|
|
1056
|
+
lines.push(
|
|
1057
|
+
"└────────────────────────────────────────────────────────────────┘",
|
|
1058
|
+
);
|
|
1059
|
+
lines.push("");
|
|
1060
|
+
|
|
1061
|
+
return lines.join("\n");
|
|
1062
|
+
};
|