@aetherwing/fcp-terraform 0.1.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/LICENSE +21 -0
- package/README.md +252 -0
- package/dist/adapter.d.ts +14 -0
- package/dist/adapter.js +266 -0
- package/dist/hcl.d.ts +5 -0
- package/dist/hcl.js +176 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +14 -0
- package/dist/model.d.ts +38 -0
- package/dist/model.js +233 -0
- package/dist/ops.d.ts +4 -0
- package/dist/ops.js +346 -0
- package/dist/queries.d.ts +4 -0
- package/dist/queries.js +250 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.js +1 -0
- package/dist/verbs.d.ts +3 -0
- package/dist/verbs.js +40 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Scott Meyer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# fcp-terraform
|
|
2
|
+
|
|
3
|
+
MCP server for Terraform HCL generation through intent-level commands.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
fcp-terraform lets LLMs build Terraform configurations by describing infrastructure intent -- resources, data sources, variables, outputs -- and renders them into valid HCL. Instead of writing raw HCL syntax, the LLM sends operations like `add resource aws_instance web ami:"ami-0c55b159" instance_type:t2.micro` and fcp-terraform manages the semantic model, dependency graph, and serialization. Built on the [FCP](https://github.com/aetherwing-io/fcp) framework.
|
|
8
|
+
|
|
9
|
+
## Quick Example
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
terraform_session('new "Main Infrastructure"')
|
|
13
|
+
|
|
14
|
+
terraform([
|
|
15
|
+
'add resource aws_instance web ami:"ami-0c55b159" instance_type:t2.micro',
|
|
16
|
+
'add resource aws_s3_bucket assets bucket:"my-assets"',
|
|
17
|
+
'add variable region default:"us-east-1" type:string',
|
|
18
|
+
'add output instance_ip value:"aws_instance.web.public_ip"',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
terraform_query('plan')
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The `plan` query produces:
|
|
25
|
+
|
|
26
|
+
```hcl
|
|
27
|
+
variable "region" {
|
|
28
|
+
type = string
|
|
29
|
+
default = "us-east-1"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
resource "aws_instance" "web" {
|
|
33
|
+
ami = "ami-0c55b159"
|
|
34
|
+
instance_type = "t2.micro"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
resource "aws_s3_bucket" "assets" {
|
|
38
|
+
bucket = "my-assets"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
output "instance_ip" {
|
|
42
|
+
value = aws_instance.web.public_ip
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Available MCP Tools
|
|
47
|
+
|
|
48
|
+
| Tool | Purpose |
|
|
49
|
+
|------|---------|
|
|
50
|
+
| `terraform(ops)` | Batch mutations -- add, set, remove, connect, nest, label, style |
|
|
51
|
+
| `terraform_query(q)` | Inspect the config -- map, list, describe, plan, graph, validate, find |
|
|
52
|
+
| `terraform_session(action)` | Lifecycle -- new, open, save, checkpoint, undo, redo |
|
|
53
|
+
| `terraform_help()` | Full reference card |
|
|
54
|
+
|
|
55
|
+
### Supported Block Types
|
|
56
|
+
|
|
57
|
+
| Verb | Syntax |
|
|
58
|
+
|------|--------|
|
|
59
|
+
| `add resource` | `add resource TYPE LABEL [key:value...]` |
|
|
60
|
+
| `add provider` | `add provider PROVIDER [region:R] [key:value...]` |
|
|
61
|
+
| `add variable` | `add variable NAME [type:T] [default:V] [description:D]` |
|
|
62
|
+
| `add output` | `add output NAME value:EXPR [description:D]` |
|
|
63
|
+
| `add data` | `add data TYPE LABEL [key:value...]` |
|
|
64
|
+
| `add module` | `add module LABEL source:PATH [key:value...]` |
|
|
65
|
+
| `connect` | `connect SRC -> TGT [label:TEXT]` |
|
|
66
|
+
| `set` | `set LABEL key:value [key:value...]` |
|
|
67
|
+
| `nest` | `nest LABEL BLOCK_TYPE [key:value...]` |
|
|
68
|
+
| `remove` | `remove LABEL` or `remove @SELECTOR` |
|
|
69
|
+
|
|
70
|
+
### Selectors
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
@type:aws_instance All resources of a given type
|
|
74
|
+
@provider:aws All blocks from a given provider
|
|
75
|
+
@kind:resource All blocks of kind (resource, variable, output, data)
|
|
76
|
+
@tag:KEY or @tag:KEY=VAL Blocks matching a tag
|
|
77
|
+
@all All blocks
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
Requires Node >= 18.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm install @aetherwing/fcp-terraform
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### MCP Client Configuration
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"terraform": {
|
|
94
|
+
"command": "node",
|
|
95
|
+
"args": ["node_modules/@aetherwing/fcp-terraform/dist/index.js"]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Architecture
|
|
102
|
+
|
|
103
|
+
3-layer architecture:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
MCP Server (Intent Layer)
|
|
107
|
+
src/server/ -- Parses op strings, resolves refs, dispatches
|
|
108
|
+
|
|
|
109
|
+
Semantic Model (Domain)
|
|
110
|
+
src/model/ -- In-memory Terraform graph (resources, data, variables, outputs)
|
|
111
|
+
src/types/ -- Core TypeScript interfaces
|
|
112
|
+
|
|
|
113
|
+
Serialization (HCL)
|
|
114
|
+
src/hcl.ts -- Semantic model -> HCL text output
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Supporting modules:
|
|
118
|
+
|
|
119
|
+
- `src/ops.ts` -- Operation string parser
|
|
120
|
+
- `src/verbs.ts` -- Verb specs and reference card
|
|
121
|
+
- `src/queries.ts` -- Query dispatcher (map, plan, graph, validate, etc.)
|
|
122
|
+
- `src/adapter.ts` -- FCP core adapter
|
|
123
|
+
|
|
124
|
+
Provider is auto-detected from resource type prefixes (`aws_`, `google_`, `azurerm_`).
|
|
125
|
+
|
|
126
|
+
## Worked Example: AWS Production Web Stack
|
|
127
|
+
|
|
128
|
+
A realistic deployment showing how operations compose. This example creates a VPC with subnets, EC2, RDS, S3, IAM, and security groups -- 13 resources total.
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
terraform_session('new "Acme Corp Web Stack"')
|
|
132
|
+
|
|
133
|
+
# Provider + Variables
|
|
134
|
+
terraform([
|
|
135
|
+
'add provider aws region:us-east-1',
|
|
136
|
+
'add variable environment type:string default:"production" description:"Deployment environment"',
|
|
137
|
+
'add variable project_name type:string default:"acme-web" description:"Project name"',
|
|
138
|
+
'add variable vpc_cidr type:string default:"10.0.0.0/16" description:"VPC CIDR block"',
|
|
139
|
+
'add variable instance_type type:string default:"t3.medium" description:"EC2 instance type"',
|
|
140
|
+
'add variable db_instance_class type:string default:"db.t3.medium" description:"RDS instance class"',
|
|
141
|
+
])
|
|
142
|
+
|
|
143
|
+
# Networking
|
|
144
|
+
terraform([
|
|
145
|
+
'add resource aws_vpc main cidr_block:var.vpc_cidr enable_dns_support:true enable_dns_hostnames:true',
|
|
146
|
+
'add resource aws_subnet public_a vpc_id:aws_vpc.main.id cidr_block:"10.0.1.0/24" map_public_ip_on_launch:true',
|
|
147
|
+
'add resource aws_subnet public_b vpc_id:aws_vpc.main.id cidr_block:"10.0.2.0/24" map_public_ip_on_launch:true',
|
|
148
|
+
'add resource aws_internet_gateway igw vpc_id:aws_vpc.main.id',
|
|
149
|
+
'add resource aws_route_table public vpc_id:aws_vpc.main.id',
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
# Nested blocks for route table and security groups
|
|
153
|
+
terraform([
|
|
154
|
+
'nest public route cidr_block:"0.0.0.0/0" gateway_id:aws_internet_gateway.igw.id',
|
|
155
|
+
'add resource aws_security_group web name:"${var.project_name}-web-sg" vpc_id:aws_vpc.main.id',
|
|
156
|
+
'nest web ingress from_port:80 to_port:80 protocol:"tcp"',
|
|
157
|
+
'nest web ingress from_port:443 to_port:443 protocol:"tcp"',
|
|
158
|
+
'nest web egress from_port:0 to_port:0 protocol:"-1"',
|
|
159
|
+
])
|
|
160
|
+
|
|
161
|
+
# Compute + Database
|
|
162
|
+
terraform([
|
|
163
|
+
'add resource aws_instance webserver ami:"ami-0c55b159" instance_type:var.instance_type subnet_id:aws_subnet.public_a.id',
|
|
164
|
+
'nest webserver root_block_device volume_size:20 volume_type:"gp3"',
|
|
165
|
+
'add resource aws_db_subnet_group dbsubnet name:"${var.project_name}-db-subnet"',
|
|
166
|
+
'set dbsubnet subnet_ids:"[aws_subnet.public_a.id,aws_subnet.public_b.id]"',
|
|
167
|
+
'add resource aws_db_instance rds engine:"postgresql" instance_class:var.db_instance_class allocated_storage:20',
|
|
168
|
+
'add resource aws_s3_bucket assets bucket:"${var.project_name}-assets-${var.environment}"',
|
|
169
|
+
])
|
|
170
|
+
|
|
171
|
+
# Outputs
|
|
172
|
+
terraform([
|
|
173
|
+
'add output vpc_id value:aws_vpc.main.id',
|
|
174
|
+
'add output web_ip value:aws_instance.webserver.public_ip',
|
|
175
|
+
'add output db_endpoint value:aws_db_instance.rds.endpoint',
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
# Tags (all resources at once)
|
|
179
|
+
terraform([
|
|
180
|
+
'style main tags:"Name=${var.project_name}-vpc,Environment=${var.environment}"',
|
|
181
|
+
'style public_a tags:"Name=${var.project_name}-public-a,Environment=${var.environment}"',
|
|
182
|
+
'style public_b tags:"Name=${var.project_name}-public-b,Environment=${var.environment}"',
|
|
183
|
+
'style igw tags:"Name=${var.project_name}-igw,Environment=${var.environment}"',
|
|
184
|
+
'style webserver tags:"Name=${var.project_name}-web,Environment=${var.environment}"',
|
|
185
|
+
'style rds tags:"Name=${var.project_name}-db,Environment=${var.environment}"',
|
|
186
|
+
'style assets tags:"Name=${var.project_name}-assets,Environment=${var.environment}"',
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
# Day-2 modifications
|
|
190
|
+
terraform([
|
|
191
|
+
'label webserver app_server', # Rename
|
|
192
|
+
'set instance_type default:"t3.large"', # Change default
|
|
193
|
+
'remove assets', # Replace S3 bucket
|
|
194
|
+
'add resource aws_s3_bucket static_assets bucket:"${var.project_name}-static-${var.environment}"',
|
|
195
|
+
])
|
|
196
|
+
|
|
197
|
+
terraform_query('plan') # Export final HCL
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Capability Matrix
|
|
201
|
+
|
|
202
|
+
| Capability | Status | Notes |
|
|
203
|
+
|------------|--------|-------|
|
|
204
|
+
| Resources, data sources, modules | Supported | All Terraform block types |
|
|
205
|
+
| Variables with types and defaults | Supported | string, number, bool, list, map, object |
|
|
206
|
+
| Outputs | Supported | Simple value expressions |
|
|
207
|
+
| Nested blocks (ingress, egress, route, filter) | Supported | Via `nest` verb |
|
|
208
|
+
| Tags | Supported | Via `style` verb with `tags:"K=V,K2=V2"` |
|
|
209
|
+
| Rename blocks | Partial | `label` renames but does not cascade references |
|
|
210
|
+
| Selectors for bulk operations | Partial | `remove @selector` works; `style @selector` not yet supported |
|
|
211
|
+
| `jsonencode()` / HCL functions | Not supported | Complex expressions containing colons conflict with the DSL tokenizer |
|
|
212
|
+
| Nested block removal/editing | Not supported | Nested blocks are append-only |
|
|
213
|
+
| `count` / `for_each` meta-arguments | Not supported | `set` cannot modify meta-arguments |
|
|
214
|
+
|
|
215
|
+
## Known Limitations
|
|
216
|
+
|
|
217
|
+
The following issues were identified through competitive validation against raw HCL writing. They are tracked for resolution.
|
|
218
|
+
|
|
219
|
+
**HCL Serialization**
|
|
220
|
+
|
|
221
|
+
- **List values in nested blocks render without quotes.** Attributes like `cidr_blocks`, `security_groups`, and `values` in filter/ingress/egress blocks output `[0.0.0.0/0]` instead of `["0.0.0.0/0"]`. This produces invalid HCL. Workaround: post-process the output or use `terraform_query('plan')` output as a starting point for manual fixes.
|
|
222
|
+
|
|
223
|
+
- **String values may lose their type.** Setting `engine_version:"15"` renders as `engine_version = 15` (number). The serializer does not preserve explicit string quoting for numeric-looking values.
|
|
224
|
+
|
|
225
|
+
- **Output values with interpolation render unquoted.** An output `value:"VPC: ${aws_vpc.main.id}"` renders without surrounding quotes.
|
|
226
|
+
|
|
227
|
+
**Labels**
|
|
228
|
+
|
|
229
|
+
- **Labels are globally unique.** You cannot use `main` for both `aws_vpc.main` and `aws_internet_gateway.main` even though Terraform allows this (labels are scoped per type). Use distinct labels like `igw`, `rds`, `dbsubnet`.
|
|
230
|
+
|
|
231
|
+
- **`label` rename breaks the lookup index.** After renaming a block with `label old new`, subsequent `style new` or `set new` calls may return "block not found". The internal index is not rebuilt after rename.
|
|
232
|
+
|
|
233
|
+
**Nested Blocks**
|
|
234
|
+
|
|
235
|
+
- **Cannot remove or edit specific nested blocks.** Once added via `nest`, a nested block (ingress rule, route, filter) cannot be individually removed or modified. If a `nest` call produces an error but partially succeeds, the ghost block persists.
|
|
236
|
+
|
|
237
|
+
**Expressions**
|
|
238
|
+
|
|
239
|
+
- **JSON with colons conflicts with the DSL tokenizer.** IAM `assume_role_policy` or similar JSON-containing attributes are parsed as key:value pairs by the FCP tokenizer, producing bare JSON objects instead of quoted strings or `jsonencode()` calls.
|
|
240
|
+
|
|
241
|
+
## Development
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
npm install
|
|
245
|
+
npm run build # tsc
|
|
246
|
+
npm test # vitest, 138 tests
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## License
|
|
250
|
+
|
|
251
|
+
MIT
|
|
252
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FcpDomainAdapter, OpResult, ParsedOp } from "@aetherwing/fcp-core";
|
|
2
|
+
import type { EventLog } from "@aetherwing/fcp-core";
|
|
3
|
+
import type { TerraformConfig, TerraformEvent } from "./types.js";
|
|
4
|
+
export declare class TerraformAdapter implements FcpDomainAdapter<TerraformConfig, TerraformEvent> {
|
|
5
|
+
createEmpty(title: string, params: Record<string, string>): TerraformConfig;
|
|
6
|
+
serialize(model: TerraformConfig): string;
|
|
7
|
+
deserialize(data: Buffer | string): TerraformConfig;
|
|
8
|
+
rebuildIndices(model: TerraformConfig): void;
|
|
9
|
+
getDigest(model: TerraformConfig): string;
|
|
10
|
+
dispatchOp(op: ParsedOp, model: TerraformConfig, log: EventLog<TerraformEvent>): OpResult;
|
|
11
|
+
dispatchQuery(query: string, model: TerraformConfig): string | Promise<string>;
|
|
12
|
+
reverseEvent(event: TerraformEvent, model: TerraformConfig): void;
|
|
13
|
+
replayEvent(event: TerraformEvent, model: TerraformConfig): void;
|
|
14
|
+
}
|
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createEmptyConfig, rebuildLabelIndex, findByKind, addBlock, removeBlock, addConnection, removeConnection, createBlock, } from "./model.js";
|
|
2
|
+
import { dispatchOp } from "./ops.js";
|
|
3
|
+
import { dispatchQuery } from "./queries.js";
|
|
4
|
+
// Keep a reference to the event log for queries that need it
|
|
5
|
+
let currentEventLog;
|
|
6
|
+
export class TerraformAdapter {
|
|
7
|
+
createEmpty(title, params) {
|
|
8
|
+
const config = createEmptyConfig(title);
|
|
9
|
+
// Auto-add provider if specified
|
|
10
|
+
if (params["provider"]) {
|
|
11
|
+
const providerName = params["provider"];
|
|
12
|
+
const providerParams = {};
|
|
13
|
+
if (params["region"])
|
|
14
|
+
providerParams["region"] = params["region"];
|
|
15
|
+
const block = createBlock("provider", providerName, providerName, providerParams);
|
|
16
|
+
block.provider = providerName;
|
|
17
|
+
addBlock(config, block);
|
|
18
|
+
}
|
|
19
|
+
rebuildLabelIndex(config);
|
|
20
|
+
return config;
|
|
21
|
+
}
|
|
22
|
+
serialize(model) {
|
|
23
|
+
return JSON.stringify(configToJson(model), null, 2);
|
|
24
|
+
}
|
|
25
|
+
deserialize(data) {
|
|
26
|
+
const json = JSON.parse(typeof data === "string" ? data : data.toString());
|
|
27
|
+
return jsonToConfig(json);
|
|
28
|
+
}
|
|
29
|
+
rebuildIndices(model) {
|
|
30
|
+
rebuildLabelIndex(model);
|
|
31
|
+
}
|
|
32
|
+
getDigest(model) {
|
|
33
|
+
const resources = findByKind(model, "resource").length;
|
|
34
|
+
const variables = findByKind(model, "variable").length;
|
|
35
|
+
const outputs = findByKind(model, "output").length;
|
|
36
|
+
const providers = findByKind(model, "provider").map((p) => p.label);
|
|
37
|
+
const provStr = providers.length > 0 ? ` | ${providers.join(",")}` : "";
|
|
38
|
+
return `[${resources}r ${variables}v ${outputs}o ${model.connections.size}c${provStr}]`;
|
|
39
|
+
}
|
|
40
|
+
dispatchOp(op, model, log) {
|
|
41
|
+
currentEventLog = log;
|
|
42
|
+
return dispatchOp(op, model, log);
|
|
43
|
+
}
|
|
44
|
+
dispatchQuery(query, model) {
|
|
45
|
+
return dispatchQuery(query, model, currentEventLog);
|
|
46
|
+
}
|
|
47
|
+
reverseEvent(event, model) {
|
|
48
|
+
switch (event.type) {
|
|
49
|
+
case "block_added":
|
|
50
|
+
removeBlock(model, event.block.id);
|
|
51
|
+
break;
|
|
52
|
+
case "block_removed": {
|
|
53
|
+
const restored = restoreBlock(event.block);
|
|
54
|
+
addBlock(model, restored);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case "attribute_set": {
|
|
58
|
+
const block = model.blocks.get(event.blockId);
|
|
59
|
+
if (!block)
|
|
60
|
+
return;
|
|
61
|
+
if (event.before === null) {
|
|
62
|
+
block.attributes.delete(event.key);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
block.attributes.set(event.key, structuredClone(event.before));
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "attribute_removed": {
|
|
70
|
+
const block = model.blocks.get(event.blockId);
|
|
71
|
+
if (!block)
|
|
72
|
+
return;
|
|
73
|
+
block.attributes.set(event.key, structuredClone(event.before));
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case "connection_added":
|
|
77
|
+
removeConnection(model, event.connection.id);
|
|
78
|
+
break;
|
|
79
|
+
case "connection_removed":
|
|
80
|
+
addConnection(model, structuredClone(event.connection));
|
|
81
|
+
break;
|
|
82
|
+
case "tag_set": {
|
|
83
|
+
const block = model.blocks.get(event.blockId);
|
|
84
|
+
if (!block)
|
|
85
|
+
return;
|
|
86
|
+
if (event.before === null) {
|
|
87
|
+
block.tags.delete(event.key);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
block.tags.set(event.key, event.before);
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "tag_removed": {
|
|
95
|
+
const block = model.blocks.get(event.blockId);
|
|
96
|
+
if (!block)
|
|
97
|
+
return;
|
|
98
|
+
block.tags.set(event.key, event.before);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case "nested_block_added": {
|
|
102
|
+
const block = model.blocks.get(event.blockId);
|
|
103
|
+
if (!block)
|
|
104
|
+
return;
|
|
105
|
+
block.nestedBlocks = block.nestedBlocks.filter((nb) => nb.id !== event.nestedBlock.id);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "nested_block_removed": {
|
|
109
|
+
const block = model.blocks.get(event.blockId);
|
|
110
|
+
if (!block)
|
|
111
|
+
return;
|
|
112
|
+
block.nestedBlocks.push(restoreNestedBlock(event.nestedBlock));
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "block_renamed": {
|
|
116
|
+
const block = model.blocks.get(event.blockId);
|
|
117
|
+
if (block)
|
|
118
|
+
block.label = event.before;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case "title_changed":
|
|
122
|
+
model.title = event.before;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
rebuildLabelIndex(model);
|
|
126
|
+
}
|
|
127
|
+
replayEvent(event, model) {
|
|
128
|
+
switch (event.type) {
|
|
129
|
+
case "block_added": {
|
|
130
|
+
const restored = restoreBlock(event.block);
|
|
131
|
+
addBlock(model, restored);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "block_removed":
|
|
135
|
+
removeBlock(model, event.block.id);
|
|
136
|
+
break;
|
|
137
|
+
case "attribute_set": {
|
|
138
|
+
const block = model.blocks.get(event.blockId);
|
|
139
|
+
if (!block)
|
|
140
|
+
return;
|
|
141
|
+
block.attributes.set(event.key, structuredClone(event.after));
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "attribute_removed": {
|
|
145
|
+
const block = model.blocks.get(event.blockId);
|
|
146
|
+
if (!block)
|
|
147
|
+
return;
|
|
148
|
+
block.attributes.delete(event.key);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "connection_added":
|
|
152
|
+
addConnection(model, structuredClone(event.connection));
|
|
153
|
+
break;
|
|
154
|
+
case "connection_removed":
|
|
155
|
+
removeConnection(model, event.connection.id);
|
|
156
|
+
break;
|
|
157
|
+
case "tag_set": {
|
|
158
|
+
const block = model.blocks.get(event.blockId);
|
|
159
|
+
if (!block)
|
|
160
|
+
return;
|
|
161
|
+
block.tags.set(event.key, event.after);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case "tag_removed": {
|
|
165
|
+
const block = model.blocks.get(event.blockId);
|
|
166
|
+
if (!block)
|
|
167
|
+
return;
|
|
168
|
+
block.tags.delete(event.key);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "nested_block_added": {
|
|
172
|
+
const block = model.blocks.get(event.blockId);
|
|
173
|
+
if (!block)
|
|
174
|
+
return;
|
|
175
|
+
block.nestedBlocks.push(restoreNestedBlock(event.nestedBlock));
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case "nested_block_removed": {
|
|
179
|
+
const block = model.blocks.get(event.blockId);
|
|
180
|
+
if (!block)
|
|
181
|
+
return;
|
|
182
|
+
block.nestedBlocks = block.nestedBlocks.filter((nb) => nb.id !== event.nestedBlock.id);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "block_renamed": {
|
|
186
|
+
const block = model.blocks.get(event.blockId);
|
|
187
|
+
if (block)
|
|
188
|
+
block.label = event.after;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case "title_changed":
|
|
192
|
+
model.title = event.after;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
rebuildLabelIndex(model);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ── Serialization helpers ─────────────────────────────
|
|
199
|
+
function configToJson(config) {
|
|
200
|
+
return {
|
|
201
|
+
id: config.id,
|
|
202
|
+
title: config.title,
|
|
203
|
+
filePath: config.filePath,
|
|
204
|
+
blockOrder: config.blockOrder,
|
|
205
|
+
blocks: Object.fromEntries([...config.blocks.entries()].map(([id, block]) => [id, blockToJson(block)])),
|
|
206
|
+
connections: Object.fromEntries([...config.connections.entries()].map(([id, conn]) => [id, conn])),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function blockToJson(block) {
|
|
210
|
+
return {
|
|
211
|
+
...block,
|
|
212
|
+
attributes: Object.fromEntries(block.attributes),
|
|
213
|
+
tags: Object.fromEntries(block.tags),
|
|
214
|
+
nestedBlocks: block.nestedBlocks.map((nb) => ({
|
|
215
|
+
id: nb.id,
|
|
216
|
+
type: nb.type,
|
|
217
|
+
attributes: Object.fromEntries(nb.attributes),
|
|
218
|
+
})),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function jsonToConfig(json) {
|
|
222
|
+
const blocks = new Map();
|
|
223
|
+
const jsonBlocks = json["blocks"];
|
|
224
|
+
for (const [id, jb] of Object.entries(jsonBlocks)) {
|
|
225
|
+
blocks.set(id, restoreBlock(jb));
|
|
226
|
+
}
|
|
227
|
+
const connections = new Map();
|
|
228
|
+
const jsonConns = json["connections"];
|
|
229
|
+
for (const [id, conn] of Object.entries(jsonConns)) {
|
|
230
|
+
connections.set(id, conn);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
id: json["id"],
|
|
234
|
+
title: json["title"],
|
|
235
|
+
filePath: json["filePath"],
|
|
236
|
+
blocks,
|
|
237
|
+
connections,
|
|
238
|
+
blockOrder: json["blockOrder"],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function restoreBlock(data) {
|
|
242
|
+
const attrs = data.attributes instanceof Map
|
|
243
|
+
? data.attributes
|
|
244
|
+
: new Map(Object.entries(data.attributes));
|
|
245
|
+
const tags = data.tags instanceof Map
|
|
246
|
+
? data.tags
|
|
247
|
+
: new Map(Object.entries(data.tags));
|
|
248
|
+
const nestedBlocks = (data.nestedBlocks ?? []).map(restoreNestedBlock);
|
|
249
|
+
return {
|
|
250
|
+
id: data.id,
|
|
251
|
+
kind: data.kind,
|
|
252
|
+
label: data.label,
|
|
253
|
+
fullType: data.fullType,
|
|
254
|
+
provider: data.provider,
|
|
255
|
+
attributes: attrs,
|
|
256
|
+
nestedBlocks,
|
|
257
|
+
tags,
|
|
258
|
+
meta: data.meta ?? { dependsOn: [] },
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function restoreNestedBlock(data) {
|
|
262
|
+
const attrs = data.attributes instanceof Map
|
|
263
|
+
? data.attributes
|
|
264
|
+
: new Map(Object.entries(data.attributes));
|
|
265
|
+
return { id: data.id, type: data.type, attributes: attrs };
|
|
266
|
+
}
|