@creact-labs/creact 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 +212 -0
- package/README.md +379 -0
- package/dist/cli/commands/BuildCommand.d.ts +40 -0
- package/dist/cli/commands/BuildCommand.js +151 -0
- package/dist/cli/commands/DeployCommand.d.ts +38 -0
- package/dist/cli/commands/DeployCommand.js +194 -0
- package/dist/cli/commands/DevCommand.d.ts +52 -0
- package/dist/cli/commands/DevCommand.js +385 -0
- package/dist/cli/commands/PlanCommand.d.ts +39 -0
- package/dist/cli/commands/PlanCommand.js +164 -0
- package/dist/cli/commands/index.d.ts +36 -0
- package/dist/cli/commands/index.js +43 -0
- package/dist/cli/core/ArgumentParser.d.ts +46 -0
- package/dist/cli/core/ArgumentParser.js +127 -0
- package/dist/cli/core/BaseCommand.d.ts +75 -0
- package/dist/cli/core/BaseCommand.js +95 -0
- package/dist/cli/core/CLIContext.d.ts +68 -0
- package/dist/cli/core/CLIContext.js +183 -0
- package/dist/cli/core/CommandRegistry.d.ts +64 -0
- package/dist/cli/core/CommandRegistry.js +89 -0
- package/dist/cli/core/index.d.ts +36 -0
- package/dist/cli/core/index.js +43 -0
- package/dist/cli/index.d.ts +35 -0
- package/dist/cli/index.js +100 -0
- package/dist/cli/output.d.ts +204 -0
- package/dist/cli/output.js +437 -0
- package/dist/cli/utils.d.ts +59 -0
- package/dist/cli/utils.js +76 -0
- package/dist/context/createContext.d.ts +90 -0
- package/dist/context/createContext.js +113 -0
- package/dist/context/index.d.ts +30 -0
- package/dist/context/index.js +35 -0
- package/dist/core/CReact.d.ts +409 -0
- package/dist/core/CReact.js +1127 -0
- package/dist/core/CloudDOMBuilder.d.ts +429 -0
- package/dist/core/CloudDOMBuilder.js +1198 -0
- package/dist/core/ContextDependencyTracker.d.ts +165 -0
- package/dist/core/ContextDependencyTracker.js +448 -0
- package/dist/core/ErrorRecoveryManager.d.ts +145 -0
- package/dist/core/ErrorRecoveryManager.js +443 -0
- package/dist/core/EventBus.d.ts +91 -0
- package/dist/core/EventBus.js +185 -0
- package/dist/core/ProviderOutputTracker.d.ts +211 -0
- package/dist/core/ProviderOutputTracker.js +476 -0
- package/dist/core/ReactiveUpdateQueue.d.ts +76 -0
- package/dist/core/ReactiveUpdateQueue.js +121 -0
- package/dist/core/Reconciler.d.ts +415 -0
- package/dist/core/Reconciler.js +1037 -0
- package/dist/core/RenderScheduler.d.ts +153 -0
- package/dist/core/RenderScheduler.js +519 -0
- package/dist/core/Renderer.d.ts +276 -0
- package/dist/core/Renderer.js +791 -0
- package/dist/core/Runtime.d.ts +246 -0
- package/dist/core/Runtime.js +640 -0
- package/dist/core/StateBindingManager.d.ts +121 -0
- package/dist/core/StateBindingManager.js +309 -0
- package/dist/core/StateMachine.d.ts +424 -0
- package/dist/core/StateMachine.js +787 -0
- package/dist/core/StructuralChangeDetector.d.ts +140 -0
- package/dist/core/StructuralChangeDetector.js +363 -0
- package/dist/core/Validator.d.ts +127 -0
- package/dist/core/Validator.js +279 -0
- package/dist/core/errors.d.ts +153 -0
- package/dist/core/errors.js +202 -0
- package/dist/core/index.d.ts +38 -0
- package/dist/core/index.js +64 -0
- package/dist/core/types.d.ts +263 -0
- package/dist/core/types.js +48 -0
- package/dist/hooks/context.d.ts +147 -0
- package/dist/hooks/context.js +334 -0
- package/dist/hooks/useContext.d.ts +113 -0
- package/dist/hooks/useContext.js +169 -0
- package/dist/hooks/useEffect.d.ts +105 -0
- package/dist/hooks/useEffect.js +540 -0
- package/dist/hooks/useInstance.d.ts +139 -0
- package/dist/hooks/useInstance.js +441 -0
- package/dist/hooks/useState.d.ts +120 -0
- package/dist/hooks/useState.js +298 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +70 -0
- package/dist/jsx.d.ts +64 -0
- package/dist/jsx.js +76 -0
- package/dist/providers/DummyBackendProvider.d.ts +193 -0
- package/dist/providers/DummyBackendProvider.js +189 -0
- package/dist/providers/DummyCloudProvider.d.ts +128 -0
- package/dist/providers/DummyCloudProvider.js +157 -0
- package/dist/providers/IBackendProvider.d.ts +177 -0
- package/dist/providers/IBackendProvider.js +31 -0
- package/dist/providers/ICloudProvider.d.ts +146 -0
- package/dist/providers/ICloudProvider.js +31 -0
- package/dist/providers/index.d.ts +31 -0
- package/dist/providers/index.js +31 -0
- package/dist/test-event-callbacks.d.ts +0 -0
- package/dist/test-event-callbacks.js +1 -0
- package/dist/utils/Logger.d.ts +144 -0
- package/dist/utils/Logger.js +220 -0
- package/dist/utils/Output.d.ts +161 -0
- package/dist/utils/Output.js +401 -0
- package/dist/utils/deepEqual.d.ts +71 -0
- package/dist/utils/deepEqual.js +276 -0
- package/dist/utils/naming.d.ts +241 -0
- package/dist/utils/naming.js +376 -0
- package/package.json +87 -0
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
|
|
10
|
+
*
|
|
11
|
+
|
|
12
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
13
|
+
|
|
14
|
+
*
|
|
15
|
+
|
|
16
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
17
|
+
|
|
18
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
19
|
+
|
|
20
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
21
|
+
|
|
22
|
+
* See the License for the specific language governing permissions and
|
|
23
|
+
|
|
24
|
+
* limitations under the License.
|
|
25
|
+
|
|
26
|
+
*
|
|
27
|
+
|
|
28
|
+
* Copyright 2025 Daniel Coutinho Ribeiro
|
|
29
|
+
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.Reconciler = void 0;
|
|
33
|
+
exports.getTotalChanges = getTotalChanges;
|
|
34
|
+
exports.hasChanges = hasChanges;
|
|
35
|
+
const deepEqual_1 = require("../utils/deepEqual");
|
|
36
|
+
const errors_1 = require("./errors");
|
|
37
|
+
const Logger_1 = require("../utils/Logger");
|
|
38
|
+
const logger = Logger_1.LoggerFactory.getLogger('reconciler');
|
|
39
|
+
/**
|
|
40
|
+
* Helper function to get total number of changes from a ChangeSet
|
|
41
|
+
* Single source of truth for change counting
|
|
42
|
+
*/
|
|
43
|
+
function getTotalChanges(changeSet) {
|
|
44
|
+
return (changeSet.creates.length +
|
|
45
|
+
changeSet.updates.length +
|
|
46
|
+
changeSet.deletes.length +
|
|
47
|
+
changeSet.replacements.length +
|
|
48
|
+
changeSet.moves.length);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Helper function to check if a ChangeSet has any changes
|
|
52
|
+
*/
|
|
53
|
+
function hasChanges(changeSet) {
|
|
54
|
+
return getTotalChanges(changeSet) > 0;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Reconciler computes minimal change sets between CloudDOM states
|
|
58
|
+
*
|
|
59
|
+
* This is CReact's equivalent to React's Fiber reconciliation algorithm.
|
|
60
|
+
* It enables:
|
|
61
|
+
* - Incremental updates (only deploy what changed)
|
|
62
|
+
* - Plan preview (show diff before deploy)
|
|
63
|
+
* - Hot reload (apply deltas without full rebuild)
|
|
64
|
+
* - Dependency-aware ordering (deploy in correct order)
|
|
65
|
+
*
|
|
66
|
+
* REQ-O01: CloudDOM State Machine
|
|
67
|
+
* REQ-O04: Plan and Change Preview
|
|
68
|
+
*/
|
|
69
|
+
class Reconciler {
|
|
70
|
+
constructor() {
|
|
71
|
+
/**
|
|
72
|
+
* Internal methods exposed for testing
|
|
73
|
+
*
|
|
74
|
+
* These are pure functions that are ideal for unit testing.
|
|
75
|
+
* Prefixed with __ to indicate they're internal/testing-only.
|
|
76
|
+
*/
|
|
77
|
+
this.__testing__ = {
|
|
78
|
+
buildDependencyGraph: this.buildDependencyGraph.bind(this),
|
|
79
|
+
detectChangeType: this.detectChangeType.bind(this),
|
|
80
|
+
computeParallelBatches: this.computeParallelBatches.bind(this),
|
|
81
|
+
topologicalSort: this.topologicalSort.bind(this),
|
|
82
|
+
extractDependencies: this.extractDependencies.bind(this),
|
|
83
|
+
detectMoves: this.detectMoves.bind(this),
|
|
84
|
+
validateGraph: this.validateGraph.bind(this),
|
|
85
|
+
computeShallowHash: this.computeShallowHash.bind(this),
|
|
86
|
+
isMetadataKey: this.isMetadataKey.bind(this),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Generate a human-readable diff visualization for UI/CLI
|
|
91
|
+
*
|
|
92
|
+
* Formats the ChangeSet as JSON suitable for:
|
|
93
|
+
* - CLI diff previews (like Terraform plans)
|
|
94
|
+
* - Web UI diff visualization
|
|
95
|
+
* - CI/CD pipeline reports
|
|
96
|
+
*
|
|
97
|
+
* @param changeSet - ChangeSet to visualize
|
|
98
|
+
* @returns JSON-serializable diff visualization
|
|
99
|
+
*/
|
|
100
|
+
generateDiffVisualization(changeSet) {
|
|
101
|
+
const changes = [];
|
|
102
|
+
// Add creates
|
|
103
|
+
for (const node of changeSet.creates) {
|
|
104
|
+
changes.push({
|
|
105
|
+
type: 'create',
|
|
106
|
+
nodeId: node.id,
|
|
107
|
+
details: {
|
|
108
|
+
construct: this.getConstructName(node.construct),
|
|
109
|
+
path: node.path,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// Add updates
|
|
114
|
+
for (const node of changeSet.updates) {
|
|
115
|
+
changes.push({
|
|
116
|
+
type: 'update',
|
|
117
|
+
nodeId: node.id,
|
|
118
|
+
details: {
|
|
119
|
+
construct: this.getConstructName(node.construct),
|
|
120
|
+
path: node.path,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Add deletes
|
|
125
|
+
for (const node of changeSet.deletes) {
|
|
126
|
+
changes.push({
|
|
127
|
+
type: 'delete',
|
|
128
|
+
nodeId: node.id,
|
|
129
|
+
details: {
|
|
130
|
+
construct: this.getConstructName(node.construct),
|
|
131
|
+
path: node.path,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// Add replacements
|
|
136
|
+
for (const node of changeSet.replacements) {
|
|
137
|
+
changes.push({
|
|
138
|
+
type: 'replacement',
|
|
139
|
+
nodeId: node.id,
|
|
140
|
+
details: {
|
|
141
|
+
construct: this.getConstructName(node.construct),
|
|
142
|
+
path: node.path,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Add moves
|
|
147
|
+
for (const move of changeSet.moves) {
|
|
148
|
+
changes.push({
|
|
149
|
+
type: 'move',
|
|
150
|
+
nodeId: move.nodeId,
|
|
151
|
+
details: {
|
|
152
|
+
from: move.from,
|
|
153
|
+
to: move.to,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// Format batches
|
|
158
|
+
const batches = changeSet.parallelBatches.map((batch, index) => ({
|
|
159
|
+
depth: index,
|
|
160
|
+
nodes: batch,
|
|
161
|
+
parallelism: batch.length,
|
|
162
|
+
}));
|
|
163
|
+
return {
|
|
164
|
+
summary: {
|
|
165
|
+
creates: changeSet.creates.length,
|
|
166
|
+
updates: changeSet.updates.length,
|
|
167
|
+
deletes: changeSet.deletes.length,
|
|
168
|
+
replacements: changeSet.replacements.length,
|
|
169
|
+
moves: changeSet.moves.length,
|
|
170
|
+
total: changes.length,
|
|
171
|
+
},
|
|
172
|
+
changes,
|
|
173
|
+
deployment: {
|
|
174
|
+
order: changeSet.deploymentOrder,
|
|
175
|
+
batches,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Debug logging helper
|
|
181
|
+
* Logs messages when CREACT_DEBUG environment variable is set
|
|
182
|
+
*
|
|
183
|
+
* Supports both string messages and structured data for telemetry.
|
|
184
|
+
* Includes timestamps for async reconciliation tracing.
|
|
185
|
+
*/
|
|
186
|
+
log(message) {
|
|
187
|
+
if (typeof message === 'string') {
|
|
188
|
+
logger.debug(message);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
logger.debug(JSON.stringify(message, null, 2));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check if a key is internal metadata (starts with underscore)
|
|
196
|
+
*
|
|
197
|
+
* Used for filtering metadata from prop comparisons and dependency scanning.
|
|
198
|
+
*
|
|
199
|
+
* @param key - Property key to check
|
|
200
|
+
* @returns True if key is metadata
|
|
201
|
+
*/
|
|
202
|
+
isMetadataKey(key) {
|
|
203
|
+
return key.startsWith('_');
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Compute a shallow hash of props for fast equality checks
|
|
207
|
+
*
|
|
208
|
+
* Creates a stable, key-order-independent hash by:
|
|
209
|
+
* - Filtering out special props (metadata, key, children)
|
|
210
|
+
* - Sorting entries by key
|
|
211
|
+
* - JSON stringifying the result
|
|
212
|
+
*
|
|
213
|
+
* This enables O(1) prop comparisons for unchanged nodes.
|
|
214
|
+
*
|
|
215
|
+
* @param props - Props object to hash
|
|
216
|
+
* @returns Stable hash string
|
|
217
|
+
*/
|
|
218
|
+
computeShallowHash(props) {
|
|
219
|
+
if (!props || typeof props !== 'object') {
|
|
220
|
+
return 'null';
|
|
221
|
+
}
|
|
222
|
+
const entries = Object.entries(props)
|
|
223
|
+
.filter(([k]) => !this.isMetadataKey(k) && k !== 'key' && k !== 'children')
|
|
224
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
225
|
+
try {
|
|
226
|
+
return JSON.stringify(entries);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// If serialization fails (circular refs, functions), use fallback
|
|
230
|
+
return `hash:${Math.random()}`;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Reconcile two CloudDOM states and compute minimal change set
|
|
235
|
+
*
|
|
236
|
+
* Algorithm:
|
|
237
|
+
* 1. Build ID maps for O(n) lookup
|
|
238
|
+
* 2. Detect creates (in current, not in previous)
|
|
239
|
+
* 3. Detect updates/replacements (in both, but props or construct changed)
|
|
240
|
+
* 4. Detect deletes (in previous, not in current)
|
|
241
|
+
* 5. Detect moves (nodes that changed parent)
|
|
242
|
+
* 6. Build dependency graph from current nodes
|
|
243
|
+
* 7. Compute topological sort for deployment order
|
|
244
|
+
* 8. Group independent resources into parallel batches
|
|
245
|
+
*
|
|
246
|
+
* Performance notes:
|
|
247
|
+
* - Synchronous for graphs <10k nodes
|
|
248
|
+
* - For larger graphs, consider async version with periodic yielding
|
|
249
|
+
* - Uses memoized deep equality for prop comparison
|
|
250
|
+
*
|
|
251
|
+
* REQ-O01: Diff algorithm for incremental updates
|
|
252
|
+
* REQ-O04: Change preview for plan command
|
|
253
|
+
*
|
|
254
|
+
* @param previous - Previous CloudDOM state
|
|
255
|
+
* @param current - Current CloudDOM state
|
|
256
|
+
* @returns ChangeSet with creates, updates, deletes, replacements, and deployment order
|
|
257
|
+
*/
|
|
258
|
+
reconcile(previous, current) {
|
|
259
|
+
this.log('Starting reconciliation');
|
|
260
|
+
// Step 1: Build ID maps for O(n) lookup
|
|
261
|
+
const previousMap = this.buildNodeMap(previous);
|
|
262
|
+
const currentMap = this.buildNodeMap(current);
|
|
263
|
+
this.log(`Previous: ${previousMap.size} nodes, Current: ${currentMap.size} nodes`);
|
|
264
|
+
// Step 2: Detect creates (nodes in current but not in previous)
|
|
265
|
+
const creates = [];
|
|
266
|
+
for (const [id, node] of currentMap) {
|
|
267
|
+
if (!previousMap.has(id)) {
|
|
268
|
+
creates.push(node);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
this.log(`Creates: ${creates.length} nodes`);
|
|
272
|
+
// Step 3: Detect updates and replacements (nodes in both with changes)
|
|
273
|
+
const updates = [];
|
|
274
|
+
const replacements = [];
|
|
275
|
+
for (const [id, currentNode] of currentMap) {
|
|
276
|
+
const previousNode = previousMap.get(id);
|
|
277
|
+
if (previousNode) {
|
|
278
|
+
const changeType = this.detectChangeType(previousNode, currentNode);
|
|
279
|
+
if (changeType === 'replacement') {
|
|
280
|
+
// Construct type changed - needs replacement (delete + create)
|
|
281
|
+
replacements.push(currentNode);
|
|
282
|
+
this.log(`Replacement detected: ${id} (construct changed)`);
|
|
283
|
+
}
|
|
284
|
+
else if (changeType === 'update') {
|
|
285
|
+
// Props changed but construct is same - can update in place
|
|
286
|
+
updates.push(currentNode);
|
|
287
|
+
this.log(`Update detected: ${id} (props changed)`);
|
|
288
|
+
}
|
|
289
|
+
// changeType === 'none' means no changes
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
this.log(`Updates: ${updates.length} nodes, Replacements: ${replacements.length} nodes`);
|
|
293
|
+
// Step 4: Detect deletes (nodes in previous but not in current)
|
|
294
|
+
const deletes = [];
|
|
295
|
+
for (const [id, node] of previousMap) {
|
|
296
|
+
if (!currentMap.has(id)) {
|
|
297
|
+
deletes.push(node);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
this.log(`Deletes: ${deletes.length} nodes`);
|
|
301
|
+
// Step 4.5: Detect moves (nodes that changed parent in hierarchy)
|
|
302
|
+
const moves = this.detectMoves(previousMap, currentMap);
|
|
303
|
+
this.log(`Moves: ${moves.length} nodes`);
|
|
304
|
+
// Debug logging: breakdown of change types (CREACT_DEBUG only)
|
|
305
|
+
this.log('\n🔍 Reconciliation Change Breakdown:');
|
|
306
|
+
this.log(` Creates: ${creates.length} nodes - ${creates.map((n) => n.id).join(', ') || 'none'}`);
|
|
307
|
+
this.log(` Updates: ${updates.length} nodes - ${updates.map((n) => n.id).join(', ') || 'none'}`);
|
|
308
|
+
this.log(` Deletes: ${deletes.length} nodes - ${deletes.map((n) => n.id).join(', ') || 'none'}`);
|
|
309
|
+
this.log(` Replacements: ${replacements.length} nodes - ${replacements.map((n) => n.id).join(', ') || 'none'}`);
|
|
310
|
+
this.log(` Moves: ${moves.length} nodes`);
|
|
311
|
+
if (moves.length > 0) {
|
|
312
|
+
this.log(' Move details:');
|
|
313
|
+
moves.forEach((move) => {
|
|
314
|
+
this.log(` ${move.nodeId}: "${move.from}" → "${move.to}"`);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
// Calculate unchanged nodes for idempotency verification
|
|
318
|
+
const unchanged = currentMap.size - (creates.length + updates.length + replacements.length);
|
|
319
|
+
this.log(`Unchanged: ${unchanged} nodes (idempotent)`);
|
|
320
|
+
// Step 5: Build dependency graph from current nodes
|
|
321
|
+
this.log('Building dependency graph');
|
|
322
|
+
const graph = this.buildDependencyGraph(Array.from(currentMap.values()));
|
|
323
|
+
// Step 6: Compute topological sort for deployment order
|
|
324
|
+
this.log('Computing deployment order');
|
|
325
|
+
const deploymentOrder = this.topologicalSort(graph);
|
|
326
|
+
// Step 7: Compute parallel deployment batches
|
|
327
|
+
this.log('Computing parallel batches');
|
|
328
|
+
const parallelBatches = this.computeParallelBatches(deploymentOrder, graph);
|
|
329
|
+
this.log(`Deployment order: ${deploymentOrder.length} nodes in ${parallelBatches.length} batches`);
|
|
330
|
+
// Structured logging for telemetry/debugging
|
|
331
|
+
this.log({
|
|
332
|
+
phase: 'reconciliation_complete',
|
|
333
|
+
summary: {
|
|
334
|
+
creates: creates.length,
|
|
335
|
+
updates: updates.length,
|
|
336
|
+
deletes: deletes.length,
|
|
337
|
+
replacements: replacements.length,
|
|
338
|
+
unchanged,
|
|
339
|
+
total: currentMap.size,
|
|
340
|
+
},
|
|
341
|
+
deployment: {
|
|
342
|
+
order: deploymentOrder,
|
|
343
|
+
batches: parallelBatches.length,
|
|
344
|
+
maxParallelism: Math.max(...parallelBatches.map((b) => b.length), 0),
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
return {
|
|
348
|
+
creates,
|
|
349
|
+
updates,
|
|
350
|
+
deletes,
|
|
351
|
+
replacements,
|
|
352
|
+
moves,
|
|
353
|
+
deploymentOrder,
|
|
354
|
+
parallelBatches,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Async reconcile for large CloudDOM graphs (>10k nodes)
|
|
359
|
+
*
|
|
360
|
+
* Yields periodically to prevent blocking the event loop.
|
|
361
|
+
* Useful for:
|
|
362
|
+
* - Large infrastructure graphs
|
|
363
|
+
* - UI responsiveness during diff computation
|
|
364
|
+
* - Long-running CI/CD pipelines
|
|
365
|
+
*
|
|
366
|
+
* Algorithm is identical to synchronous reconcile, but yields every N nodes.
|
|
367
|
+
*
|
|
368
|
+
* @param previous - Previous CloudDOM state
|
|
369
|
+
* @param current - Current CloudDOM state
|
|
370
|
+
* @param yieldInterval - Number of nodes to process before yielding (default: 1000)
|
|
371
|
+
* @returns Promise resolving to ChangeSet
|
|
372
|
+
*/
|
|
373
|
+
async reconcileAsync(previous, current, yieldInterval = 1000) {
|
|
374
|
+
this.log('Starting async reconciliation');
|
|
375
|
+
// Step 1: Build ID maps for O(n) lookup
|
|
376
|
+
const previousMap = this.buildNodeMap(previous);
|
|
377
|
+
const currentMap = this.buildNodeMap(current);
|
|
378
|
+
this.log(`Previous: ${previousMap.size} nodes, Current: ${currentMap.size} nodes`);
|
|
379
|
+
// Step 2: Detect creates (with periodic yielding)
|
|
380
|
+
const creates = [];
|
|
381
|
+
let processedCount = 0;
|
|
382
|
+
for (const [id, node] of currentMap) {
|
|
383
|
+
if (!previousMap.has(id)) {
|
|
384
|
+
creates.push(node);
|
|
385
|
+
}
|
|
386
|
+
// Yield periodically to prevent blocking
|
|
387
|
+
if (++processedCount % yieldInterval === 0) {
|
|
388
|
+
await this.yield();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
this.log(`Creates: ${creates.length} nodes`);
|
|
392
|
+
// Step 3: Detect updates and replacements (with periodic yielding)
|
|
393
|
+
const updates = [];
|
|
394
|
+
const replacements = [];
|
|
395
|
+
processedCount = 0;
|
|
396
|
+
for (const [id, currentNode] of currentMap) {
|
|
397
|
+
const previousNode = previousMap.get(id);
|
|
398
|
+
if (previousNode) {
|
|
399
|
+
const changeType = this.detectChangeType(previousNode, currentNode);
|
|
400
|
+
if (changeType === 'replacement') {
|
|
401
|
+
replacements.push(currentNode);
|
|
402
|
+
this.log(`Replacement detected: ${id} (construct changed)`);
|
|
403
|
+
}
|
|
404
|
+
else if (changeType === 'update') {
|
|
405
|
+
updates.push(currentNode);
|
|
406
|
+
this.log(`Update detected: ${id} (props changed)`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Yield periodically
|
|
410
|
+
if (++processedCount % yieldInterval === 0) {
|
|
411
|
+
await this.yield();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
this.log(`Updates: ${updates.length} nodes, Replacements: ${replacements.length} nodes`);
|
|
415
|
+
// Step 4: Detect deletes
|
|
416
|
+
const deletes = [];
|
|
417
|
+
for (const [id, node] of previousMap) {
|
|
418
|
+
if (!currentMap.has(id)) {
|
|
419
|
+
deletes.push(node);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
this.log(`Deletes: ${deletes.length} nodes`);
|
|
423
|
+
// Step 4.5: Detect moves
|
|
424
|
+
const moves = this.detectMoves(previousMap, currentMap);
|
|
425
|
+
this.log(`Moves: ${moves.length} nodes`);
|
|
426
|
+
// Calculate unchanged nodes
|
|
427
|
+
const unchanged = currentMap.size - (creates.length + updates.length + replacements.length);
|
|
428
|
+
this.log(`Unchanged: ${unchanged} nodes (idempotent)`);
|
|
429
|
+
// Step 5: Build dependency graph
|
|
430
|
+
this.log('Building dependency graph');
|
|
431
|
+
const graph = this.buildDependencyGraph(Array.from(currentMap.values()));
|
|
432
|
+
// Step 6: Compute topological sort
|
|
433
|
+
this.log('Computing deployment order');
|
|
434
|
+
const deploymentOrder = this.topologicalSort(graph);
|
|
435
|
+
// Step 7: Compute parallel batches
|
|
436
|
+
this.log('Computing parallel batches');
|
|
437
|
+
const parallelBatches = this.computeParallelBatches(deploymentOrder, graph);
|
|
438
|
+
this.log(`Deployment order: ${deploymentOrder.length} nodes in ${parallelBatches.length} batches`);
|
|
439
|
+
// Structured logging
|
|
440
|
+
this.log({
|
|
441
|
+
phase: 'async_reconciliation_complete',
|
|
442
|
+
summary: {
|
|
443
|
+
creates: creates.length,
|
|
444
|
+
updates: updates.length,
|
|
445
|
+
deletes: deletes.length,
|
|
446
|
+
replacements: replacements.length,
|
|
447
|
+
unchanged,
|
|
448
|
+
total: currentMap.size,
|
|
449
|
+
},
|
|
450
|
+
deployment: {
|
|
451
|
+
order: deploymentOrder,
|
|
452
|
+
batches: parallelBatches.length,
|
|
453
|
+
maxParallelism: Math.max(...parallelBatches.map((b) => b.length), 0),
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
creates,
|
|
458
|
+
updates,
|
|
459
|
+
deletes,
|
|
460
|
+
replacements,
|
|
461
|
+
moves,
|
|
462
|
+
deploymentOrder,
|
|
463
|
+
parallelBatches,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Yield control to event loop
|
|
468
|
+
*
|
|
469
|
+
* Uses setImmediate in Node.js, setTimeout in browser.
|
|
470
|
+
* Prevents blocking during large graph reconciliation.
|
|
471
|
+
*/
|
|
472
|
+
yield() {
|
|
473
|
+
return new Promise((resolve) => {
|
|
474
|
+
if (typeof setImmediate !== 'undefined') {
|
|
475
|
+
setImmediate(resolve);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
setTimeout(resolve, 0);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Build a flat map of node ID → CloudDOMNode for O(n) lookup
|
|
484
|
+
*
|
|
485
|
+
* Recursively walks the CloudDOM tree and collects all nodes.
|
|
486
|
+
*
|
|
487
|
+
* @param nodes - CloudDOM tree (root nodes)
|
|
488
|
+
* @returns Map of node ID → CloudDOMNode
|
|
489
|
+
*/
|
|
490
|
+
buildNodeMap(nodes) {
|
|
491
|
+
const map = new Map();
|
|
492
|
+
const walk = (node) => {
|
|
493
|
+
map.set(node.id, node);
|
|
494
|
+
if (node.children && node.children.length > 0) {
|
|
495
|
+
node.children.forEach(walk);
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
nodes.forEach(walk);
|
|
499
|
+
return map;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Detect moves (nodes that changed parent in hierarchy)
|
|
503
|
+
*
|
|
504
|
+
* A move is detected when:
|
|
505
|
+
* - Node exists in both previous and current
|
|
506
|
+
* - Node's parent path changed (using array equality, not string comparison)
|
|
507
|
+
*
|
|
508
|
+
* This is useful for hierarchical updates where resources move
|
|
509
|
+
* between parent containers.
|
|
510
|
+
*
|
|
511
|
+
* @param previousMap - Previous node map
|
|
512
|
+
* @param currentMap - Current node map
|
|
513
|
+
* @returns Array of move operations with node ID
|
|
514
|
+
*/
|
|
515
|
+
detectMoves(previousMap, currentMap) {
|
|
516
|
+
const moves = [];
|
|
517
|
+
for (const [id, currentNode] of currentMap) {
|
|
518
|
+
const previousNode = previousMap.get(id);
|
|
519
|
+
if (previousNode) {
|
|
520
|
+
// Get parent paths (all but last segment)
|
|
521
|
+
const prevParentPathArray = previousNode.path.slice(0, -1);
|
|
522
|
+
const currParentPathArray = currentNode.path.slice(0, -1);
|
|
523
|
+
// CRITICAL FIX: Handle empty parent paths (root nodes)
|
|
524
|
+
// Both nodes at root level (empty parent paths) - no move
|
|
525
|
+
if (prevParentPathArray.length === 0 && currParentPathArray.length === 0) {
|
|
526
|
+
this.log(`Skipping move detection for root node: ${id}`);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
// Check if parent changed using array equality (not string comparison)
|
|
530
|
+
// This handles dynamic segments correctly
|
|
531
|
+
if (!(0, deepEqual_1.deepEqual)(prevParentPathArray, currParentPathArray, false)) {
|
|
532
|
+
const prevParentPath = prevParentPathArray.join('.') || '<root>';
|
|
533
|
+
const currParentPath = currParentPathArray.join('.') || '<root>';
|
|
534
|
+
// Only add if paths are actually different strings
|
|
535
|
+
if (prevParentPath !== currParentPath) {
|
|
536
|
+
moves.push({
|
|
537
|
+
nodeId: id,
|
|
538
|
+
from: prevParentPath,
|
|
539
|
+
to: currParentPath,
|
|
540
|
+
});
|
|
541
|
+
this.log(`Move detected: ${id} from ${prevParentPath} to ${currParentPath}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return moves;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Detect the type of change between two nodes
|
|
550
|
+
*
|
|
551
|
+
* Returns:
|
|
552
|
+
* - 'replacement': Construct type changed (needs delete + create)
|
|
553
|
+
* - 'update': Props changed but construct is same (can update in place)
|
|
554
|
+
* - 'none': No changes detected
|
|
555
|
+
*
|
|
556
|
+
* Performance optimization:
|
|
557
|
+
* - Uses shallow prop hashes for O(1) comparison
|
|
558
|
+
* - Only falls back to deep equality if hashes differ
|
|
559
|
+
* - Caches hashes in node metadata (_propHash)
|
|
560
|
+
*
|
|
561
|
+
* NOTE: The `state` field is intentionally NOT compared during reconciliation.
|
|
562
|
+
* Only `props` and `outputs` are compared. The `state` field contains useState
|
|
563
|
+
* values which should not trigger infrastructure updates. This separation ensures
|
|
564
|
+
* that application state changes don't cause unnecessary resource deployments.
|
|
565
|
+
*
|
|
566
|
+
* @param previous - Previous node
|
|
567
|
+
* @param current - Current node
|
|
568
|
+
* @returns Change type
|
|
569
|
+
*/
|
|
570
|
+
detectChangeType(previous, current) {
|
|
571
|
+
var _a, _b;
|
|
572
|
+
// Check if construct type changed (needs replacement)
|
|
573
|
+
// Use constructType string field for reliable comparison after serialization
|
|
574
|
+
const prevType = previous.constructType || this.getConstructName(previous.construct);
|
|
575
|
+
const currType = current.constructType || this.getConstructName(current.construct);
|
|
576
|
+
if (prevType !== currType) {
|
|
577
|
+
logger.debug(`Construct type mismatch for ${current.id}:`, {
|
|
578
|
+
previousType: prevType,
|
|
579
|
+
currentType: currType,
|
|
580
|
+
});
|
|
581
|
+
return 'replacement';
|
|
582
|
+
}
|
|
583
|
+
// Hash-based diff acceleration: compute or reuse cached hashes
|
|
584
|
+
const prevHash = ((_a = previous)._propHash ?? (_a._propHash = this.computeShallowHash(previous.props)));
|
|
585
|
+
const currHash = ((_b = current)._propHash ?? (_b._propHash = this.computeShallowHash(current.props)));
|
|
586
|
+
logger.debug(`Hash comparison for ${current.id}:`, {
|
|
587
|
+
prevHash,
|
|
588
|
+
currHash,
|
|
589
|
+
match: prevHash === currHash,
|
|
590
|
+
prevProps: previous.props,
|
|
591
|
+
currProps: current.props,
|
|
592
|
+
});
|
|
593
|
+
// Fast path: if hashes match, props are identical (skip deep equality)
|
|
594
|
+
if (prevHash === currHash) {
|
|
595
|
+
// Props are identical - no change needed
|
|
596
|
+
// NOTE: outputs are ignored per REQ-R03 (runtime values only)
|
|
597
|
+
return 'none';
|
|
598
|
+
}
|
|
599
|
+
// Hashes differ: perform deep equality check to confirm
|
|
600
|
+
// NOTE: Only compares `props` field, NOT `outputs` or `state` fields
|
|
601
|
+
// outputs are runtime values and should not trigger reconciliation (REQ-R03)
|
|
602
|
+
if (this.propsChanged(previous.props, current.props)) {
|
|
603
|
+
return 'update';
|
|
604
|
+
}
|
|
605
|
+
return 'none';
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Check if two constructs are equal
|
|
609
|
+
*
|
|
610
|
+
* Constructs are considered equal if they have the same name.
|
|
611
|
+
* Handles edge cases like minified builds where function names may be undefined.
|
|
612
|
+
*
|
|
613
|
+
* NOTE: After serialization, construct field may be undefined, so we rely on
|
|
614
|
+
* constructType string field for comparison.
|
|
615
|
+
*
|
|
616
|
+
* @param a - First construct
|
|
617
|
+
* @param b - Second construct
|
|
618
|
+
* @returns True if constructs are equal
|
|
619
|
+
*/
|
|
620
|
+
constructsEqual(a, b) {
|
|
621
|
+
// Handle null/undefined
|
|
622
|
+
if (a === b) {
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
if (!a || !b) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
// Extract names for comparison
|
|
629
|
+
const nameA = this.getConstructName(a);
|
|
630
|
+
const nameB = this.getConstructName(b);
|
|
631
|
+
return nameA === nameB;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Get the name of a construct
|
|
635
|
+
*
|
|
636
|
+
* @param construct - Construct to get name from
|
|
637
|
+
* @returns Construct name
|
|
638
|
+
*/
|
|
639
|
+
getConstructName(construct) {
|
|
640
|
+
if (typeof construct === 'string') {
|
|
641
|
+
return construct;
|
|
642
|
+
}
|
|
643
|
+
if (typeof construct === 'function') {
|
|
644
|
+
return construct.name || construct.constructor?.name || 'anonymous';
|
|
645
|
+
}
|
|
646
|
+
if (construct && typeof construct === 'object' && 'name' in construct) {
|
|
647
|
+
return String(construct.name);
|
|
648
|
+
}
|
|
649
|
+
return 'unknown';
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Check if node props have changed using deep equality
|
|
653
|
+
*
|
|
654
|
+
* Compares props objects deeply to detect any changes.
|
|
655
|
+
* Uses the deepEqual utility with memoization for performance.
|
|
656
|
+
*
|
|
657
|
+
* Excludes special props that don't affect rendering:
|
|
658
|
+
* - Metadata props (starting with _)
|
|
659
|
+
* - key prop (used for identity, not rendering)
|
|
660
|
+
* - children prop (handled separately in rendering)
|
|
661
|
+
*
|
|
662
|
+
* @param prevProps - Previous props (optional, defaults to empty object)
|
|
663
|
+
* @param currProps - Current props (optional, defaults to empty object)
|
|
664
|
+
* @returns True if props have changed
|
|
665
|
+
*/
|
|
666
|
+
propsChanged(prevProps = {}, currProps = {}) {
|
|
667
|
+
// Filter out special props that don't affect rendering
|
|
668
|
+
const filterSpecialProps = (props) => {
|
|
669
|
+
const filtered = {};
|
|
670
|
+
for (const [key, value] of Object.entries(props)) {
|
|
671
|
+
// Exclude metadata (starts with _)
|
|
672
|
+
if (this.isMetadataKey(key)) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
// Exclude key (used for identity, not rendering)
|
|
676
|
+
if (key === 'key') {
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
// Exclude children (handled separately)
|
|
680
|
+
if (key === 'children') {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
filtered[key] = value;
|
|
684
|
+
}
|
|
685
|
+
return filtered;
|
|
686
|
+
};
|
|
687
|
+
const prevFiltered = filterSpecialProps(prevProps);
|
|
688
|
+
const currFiltered = filterSpecialProps(currProps);
|
|
689
|
+
// Use deep equality with memoization
|
|
690
|
+
return !(0, deepEqual_1.deepEqual)(prevFiltered, currFiltered);
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Check if outputs have changed using deep equality
|
|
694
|
+
*
|
|
695
|
+
* Treats empty objects ({}) as equivalent to undefined for idempotency.
|
|
696
|
+
*
|
|
697
|
+
* NOTE: This method only receives and compares the `outputs` field from CloudDOMNode.
|
|
698
|
+
* The `state` field is never passed to this method, ensuring that useState values
|
|
699
|
+
* do not trigger infrastructure updates. This separation is automatic - callers
|
|
700
|
+
* explicitly pass `node.outputs`, not `node.state`.
|
|
701
|
+
*
|
|
702
|
+
* @param previous - Previous outputs (from node.outputs field only)
|
|
703
|
+
* @param current - Current outputs (from node.outputs field only)
|
|
704
|
+
* @returns True if outputs changed
|
|
705
|
+
*/
|
|
706
|
+
outputsChanged(previous, current) {
|
|
707
|
+
// Helper to check if output is empty (undefined or {})
|
|
708
|
+
const isEmpty = (output) => {
|
|
709
|
+
return (output === undefined || (typeof output === 'object' && Object.keys(output).length === 0));
|
|
710
|
+
};
|
|
711
|
+
const prevIsEmpty = isEmpty(previous);
|
|
712
|
+
const currIsEmpty = isEmpty(current);
|
|
713
|
+
// CRITICAL FIX: Both empty - no change
|
|
714
|
+
if (prevIsEmpty && currIsEmpty) {
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
// One empty, one not - changed
|
|
718
|
+
if (prevIsEmpty !== currIsEmpty) {
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
// Both have content - use deep equality
|
|
722
|
+
return !(0, deepEqual_1.deepEqual)(previous, current, true);
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Build dependency graph from CloudDOM nodes
|
|
726
|
+
*
|
|
727
|
+
* Scans node props for references to other node IDs and builds
|
|
728
|
+
* an adjacency list representing dependencies.
|
|
729
|
+
*
|
|
730
|
+
* A node depends on another if its props reference the other node's ID.
|
|
731
|
+
*
|
|
732
|
+
* REQ-O01: Dependency graph for deployment ordering
|
|
733
|
+
*
|
|
734
|
+
* @param nodes - CloudDOM nodes
|
|
735
|
+
* @returns DependencyGraph with adjacency lists
|
|
736
|
+
*/
|
|
737
|
+
buildDependencyGraph(nodes) {
|
|
738
|
+
const dependencies = new Map();
|
|
739
|
+
const dependents = new Map();
|
|
740
|
+
// Initialize empty dependency lists for all nodes
|
|
741
|
+
for (const node of nodes) {
|
|
742
|
+
dependencies.set(node.id, []);
|
|
743
|
+
dependents.set(node.id, []);
|
|
744
|
+
}
|
|
745
|
+
// Build set of all node IDs for quick lookup
|
|
746
|
+
const nodeIds = new Set(nodes.map((n) => n.id));
|
|
747
|
+
// Scan props for references to other node IDs
|
|
748
|
+
for (const node of nodes) {
|
|
749
|
+
const deps = this.extractDependencies(node, nodeIds);
|
|
750
|
+
dependencies.set(node.id, deps);
|
|
751
|
+
// Build reverse adjacency list (dependents)
|
|
752
|
+
for (const depId of deps) {
|
|
753
|
+
if (!dependents.has(depId)) {
|
|
754
|
+
dependents.set(depId, []);
|
|
755
|
+
}
|
|
756
|
+
dependents.get(depId).push(node.id);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
// Validate graph integrity
|
|
760
|
+
this.validateGraph({ dependencies, dependents });
|
|
761
|
+
// Detect circular dependencies
|
|
762
|
+
this.detectCircularDependencies(dependencies);
|
|
763
|
+
return { dependencies, dependents };
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Validate dependency graph integrity
|
|
767
|
+
*
|
|
768
|
+
* Ensures all dependencies exist in the graph and no missing node IDs remain.
|
|
769
|
+
*
|
|
770
|
+
* @param graph - Dependency graph to validate
|
|
771
|
+
* @throws ReconciliationError if graph is invalid
|
|
772
|
+
*/
|
|
773
|
+
validateGraph(graph) {
|
|
774
|
+
const { dependencies } = graph;
|
|
775
|
+
for (const [nodeId, deps] of dependencies) {
|
|
776
|
+
for (const depId of deps) {
|
|
777
|
+
if (!dependencies.has(depId)) {
|
|
778
|
+
const errorDetails = {
|
|
779
|
+
nodeId,
|
|
780
|
+
missingDependency: depId,
|
|
781
|
+
availableNodes: Array.from(dependencies.keys()),
|
|
782
|
+
};
|
|
783
|
+
throw new errors_1.ReconciliationError(`Missing dependency: ${nodeId} → ${depId}. Referenced node does not exist in graph.`, errorDetails);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Extract dependencies from a node's props
|
|
790
|
+
*
|
|
791
|
+
* Recursively scans props object for strings that match other node IDs.
|
|
792
|
+
*
|
|
793
|
+
* Optimizations:
|
|
794
|
+
* - Skips props that start with underscore (internal metadata)
|
|
795
|
+
* - Uses explicit ref convention when available (props.ref or props.dependsOn)
|
|
796
|
+
* - Caches known non-ID props to avoid false positives
|
|
797
|
+
*
|
|
798
|
+
* @param node - CloudDOM node
|
|
799
|
+
* @param nodeIds - Set of all node IDs for validation
|
|
800
|
+
* @returns Array of dependency IDs
|
|
801
|
+
*/
|
|
802
|
+
extractDependencies(node, nodeIds) {
|
|
803
|
+
const deps = new Set();
|
|
804
|
+
// Check for explicit dependency declarations first
|
|
805
|
+
if (node.props.dependsOn) {
|
|
806
|
+
const dependsOn = Array.isArray(node.props.dependsOn)
|
|
807
|
+
? node.props.dependsOn
|
|
808
|
+
: [node.props.dependsOn];
|
|
809
|
+
for (const depId of dependsOn) {
|
|
810
|
+
if (typeof depId === 'string' && nodeIds.has(depId) && depId !== node.id) {
|
|
811
|
+
deps.add(depId);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
// Check for ref prop (common convention)
|
|
816
|
+
if (node.props.ref && typeof node.props.ref === 'string') {
|
|
817
|
+
if (nodeIds.has(node.props.ref) && node.props.ref !== node.id) {
|
|
818
|
+
deps.add(node.props.ref);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
// Scan all props for implicit dependencies
|
|
822
|
+
const scan = (value, key) => {
|
|
823
|
+
// Skip internal metadata props using shared helper
|
|
824
|
+
if (key && this.isMetadataKey(key)) {
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
// Skip known non-ID props
|
|
828
|
+
if (key && this.isKnownNonIdProp(key)) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (typeof value === 'string') {
|
|
832
|
+
// Check if this string is a node ID
|
|
833
|
+
if (nodeIds.has(value) && value !== node.id) {
|
|
834
|
+
deps.add(value);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
else if (Array.isArray(value)) {
|
|
838
|
+
value.forEach((v) => scan(v));
|
|
839
|
+
}
|
|
840
|
+
else if (value && typeof value === 'object') {
|
|
841
|
+
for (const [k, v] of Object.entries(value)) {
|
|
842
|
+
scan(v, k);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
// Scan all props
|
|
847
|
+
for (const [key, value] of Object.entries(node.props)) {
|
|
848
|
+
scan(value, key);
|
|
849
|
+
}
|
|
850
|
+
return Array.from(deps);
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Check if a prop key is known to never contain node IDs
|
|
854
|
+
*
|
|
855
|
+
* This helps avoid false positives in dependency extraction.
|
|
856
|
+
*
|
|
857
|
+
* @param key - Prop key
|
|
858
|
+
* @returns True if this prop is known to not contain IDs
|
|
859
|
+
*/
|
|
860
|
+
isKnownNonIdProp(key) {
|
|
861
|
+
const knownNonIdProps = new Set([
|
|
862
|
+
'name',
|
|
863
|
+
'description',
|
|
864
|
+
'version',
|
|
865
|
+
'region',
|
|
866
|
+
'zone',
|
|
867
|
+
'namespace',
|
|
868
|
+
'label',
|
|
869
|
+
'tag',
|
|
870
|
+
'tags',
|
|
871
|
+
'metadata',
|
|
872
|
+
'annotations',
|
|
873
|
+
'key',
|
|
874
|
+
]);
|
|
875
|
+
return knownNonIdProps.has(key);
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Detect circular dependencies using DFS
|
|
879
|
+
*
|
|
880
|
+
* Collects all cycles before throwing to provide comprehensive diagnostics.
|
|
881
|
+
*
|
|
882
|
+
* REQ-O01: Circular dependency detection
|
|
883
|
+
*
|
|
884
|
+
* @param dependencies - Dependency adjacency list
|
|
885
|
+
* @throws ReconciliationError if circular dependencies are detected
|
|
886
|
+
*/
|
|
887
|
+
detectCircularDependencies(dependencies) {
|
|
888
|
+
const visited = new Set();
|
|
889
|
+
const stack = new Set();
|
|
890
|
+
const cycles = [];
|
|
891
|
+
const dfs = (nodeId, path = []) => {
|
|
892
|
+
if (stack.has(nodeId)) {
|
|
893
|
+
// Circular dependency detected - collect it
|
|
894
|
+
const cycle = [...path, nodeId];
|
|
895
|
+
cycles.push(cycle);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (visited.has(nodeId)) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
visited.add(nodeId);
|
|
902
|
+
stack.add(nodeId);
|
|
903
|
+
const deps = dependencies.get(nodeId) || [];
|
|
904
|
+
for (const depId of deps) {
|
|
905
|
+
try {
|
|
906
|
+
dfs(depId, [...path, nodeId]);
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
// Continue checking other dependencies
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
stack.delete(nodeId);
|
|
913
|
+
};
|
|
914
|
+
// Run DFS from each node to find all cycles
|
|
915
|
+
for (const nodeId of dependencies.keys()) {
|
|
916
|
+
if (!visited.has(nodeId)) {
|
|
917
|
+
dfs(nodeId);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
// Throw if any cycles were found
|
|
921
|
+
if (cycles.length > 0) {
|
|
922
|
+
const cycleStrings = cycles.map((cycle) => cycle.join(' → '));
|
|
923
|
+
throw new errors_1.ReconciliationError(`Circular dependencies detected:\n ${cycleStrings.join('\n ')}`, {
|
|
924
|
+
cycles,
|
|
925
|
+
count: cycles.length,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Compute topological sort for deployment order using Kahn's algorithm
|
|
931
|
+
*
|
|
932
|
+
* Returns array of node IDs in deployment order (dependencies first).
|
|
933
|
+
* Nodes with same depth are sorted by ID for determinism.
|
|
934
|
+
*
|
|
935
|
+
* REQ-O01: Topological sort for deployment order
|
|
936
|
+
*
|
|
937
|
+
* @param graph - Dependency graph
|
|
938
|
+
* @returns Array of node IDs in deployment order
|
|
939
|
+
*/
|
|
940
|
+
topologicalSort(graph) {
|
|
941
|
+
const { dependencies, dependents } = graph;
|
|
942
|
+
// Calculate in-degree for each node (number of dependencies)
|
|
943
|
+
const inDegree = new Map();
|
|
944
|
+
for (const [nodeId, deps] of dependencies) {
|
|
945
|
+
inDegree.set(nodeId, deps.length);
|
|
946
|
+
}
|
|
947
|
+
// Start with nodes that have no dependencies (in-degree = 0)
|
|
948
|
+
const queue = [];
|
|
949
|
+
for (const [nodeId, degree] of inDegree) {
|
|
950
|
+
if (degree === 0) {
|
|
951
|
+
queue.push(nodeId);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
// Sort queue for determinism
|
|
955
|
+
queue.sort();
|
|
956
|
+
const result = [];
|
|
957
|
+
while (queue.length > 0) {
|
|
958
|
+
// Process nodes at same depth in sorted order for determinism
|
|
959
|
+
const batchSize = queue.length;
|
|
960
|
+
const batch = [];
|
|
961
|
+
for (let i = 0; i < batchSize; i++) {
|
|
962
|
+
const nodeId = queue.shift();
|
|
963
|
+
batch.push(nodeId);
|
|
964
|
+
// Reduce in-degree of dependents
|
|
965
|
+
const deps = dependents.get(nodeId) || [];
|
|
966
|
+
for (const depId of deps) {
|
|
967
|
+
const degree = inDegree.get(depId) - 1;
|
|
968
|
+
inDegree.set(depId, degree);
|
|
969
|
+
if (degree === 0) {
|
|
970
|
+
queue.push(depId);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// Sort batch for determinism and add to result
|
|
975
|
+
batch.sort();
|
|
976
|
+
result.push(...batch);
|
|
977
|
+
// Sort queue for next iteration
|
|
978
|
+
queue.sort();
|
|
979
|
+
}
|
|
980
|
+
// Safety guard: ensure all nodes were sorted
|
|
981
|
+
if (result.length !== dependencies.size) {
|
|
982
|
+
throw new errors_1.ReconciliationError(`Topological sort incomplete: ${result.length}/${dependencies.size} nodes sorted. Possible cycle or missing dependency.`, {
|
|
983
|
+
sorted: result,
|
|
984
|
+
expected: dependencies.size,
|
|
985
|
+
missing: Array.from(dependencies.keys()).filter((id) => !result.includes(id)),
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
return result;
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Compute parallel deployment batches
|
|
992
|
+
*
|
|
993
|
+
* Groups nodes by depth in dependency graph.
|
|
994
|
+
* Nodes at same depth can deploy in parallel (no dependencies between them).
|
|
995
|
+
*
|
|
996
|
+
* REQ-O01: Parallel deployment batches
|
|
997
|
+
*
|
|
998
|
+
* @param deploymentOrder - Topologically sorted node IDs
|
|
999
|
+
* @param graph - Dependency graph
|
|
1000
|
+
* @returns Array of batches (each batch can deploy in parallel)
|
|
1001
|
+
*/
|
|
1002
|
+
computeParallelBatches(deploymentOrder, graph) {
|
|
1003
|
+
const { dependencies } = graph;
|
|
1004
|
+
// Calculate depth for each node (max distance from a root node)
|
|
1005
|
+
const depths = new Map();
|
|
1006
|
+
for (const nodeId of deploymentOrder) {
|
|
1007
|
+
const deps = dependencies.get(nodeId) || [];
|
|
1008
|
+
if (deps.length === 0) {
|
|
1009
|
+
// Root node (no dependencies)
|
|
1010
|
+
depths.set(nodeId, 0);
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
// Depth = max(dependency depths) + 1
|
|
1014
|
+
const maxDepth = Math.max(...deps.map((depId) => depths.get(depId) || 0));
|
|
1015
|
+
depths.set(nodeId, maxDepth + 1);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Group nodes by depth
|
|
1019
|
+
const batches = new Map();
|
|
1020
|
+
for (const [nodeId, depth] of depths) {
|
|
1021
|
+
if (!batches.has(depth)) {
|
|
1022
|
+
batches.set(depth, []);
|
|
1023
|
+
}
|
|
1024
|
+
batches.get(depth).push(nodeId);
|
|
1025
|
+
}
|
|
1026
|
+
// Convert to array and sort each batch for determinism
|
|
1027
|
+
const result = [];
|
|
1028
|
+
const sortedDepths = Array.from(batches.keys()).sort((a, b) => a - b);
|
|
1029
|
+
for (const depth of sortedDepths) {
|
|
1030
|
+
const batch = batches.get(depth);
|
|
1031
|
+
batch.sort(); // Sort for determinism
|
|
1032
|
+
result.push(batch);
|
|
1033
|
+
}
|
|
1034
|
+
return result;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
exports.Reconciler = Reconciler;
|