@fuzdev/fuz_util 0.48.2 → 0.48.4
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/dist/async.d.ts +11 -0
- package/dist/async.d.ts.map +1 -1
- package/dist/async.js +30 -0
- package/dist/dag.d.ts +80 -0
- package/dist/dag.d.ts.map +1 -0
- package/dist/dag.js +156 -0
- package/dist/diff.d.ts +61 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +185 -0
- package/dist/hash.d.ts +27 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +66 -0
- package/dist/sort.d.ts +38 -0
- package/dist/sort.d.ts.map +1 -0
- package/dist/sort.js +123 -0
- package/dist/source_json.d.ts +4 -4
- package/dist/string.d.ts +9 -0
- package/dist/string.d.ts.map +1 -1
- package/dist/string.js +12 -0
- package/package.json +7 -7
- package/src/lib/async.ts +33 -0
- package/src/lib/dag.ts +240 -0
- package/src/lib/diff.ts +234 -0
- package/src/lib/hash.ts +73 -0
- package/src/lib/sort.ts +160 -0
- package/src/lib/string.ts +13 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sort.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/sort.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;GAEG;AACH,MAAM,WAAW,QAAQ;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CAC3B;AAED;;GAEG;AACH,MAAM,MAAM,qBAAqB,CAAC,CAAC,SAAS,QAAQ,IACjD;IAAC,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAA;CAAC,GAC5B;IAAC,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAA;CAAC,CAAC;AAErD;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,SAAS,QAAQ,EAClD,OAAO,KAAK,CAAC,CAAC,CAAC,EACf,QAAO,MAAe,KACpB,qBAAqB,CAAC,CAAC,CAuEzB,CAAC"}
|
package/dist/sort.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic topological sort using Kahn's algorithm.
|
|
3
|
+
*
|
|
4
|
+
* Orders items so that dependencies come before dependents.
|
|
5
|
+
* Works with any item type that has `id` and optional `depends_on`.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Sort items by their dependencies using Kahn's algorithm.
|
|
11
|
+
*
|
|
12
|
+
* Returns items ordered so that dependencies come before dependents.
|
|
13
|
+
* If a cycle is detected, returns an error with the cycle path.
|
|
14
|
+
*
|
|
15
|
+
* @param items - Array of items to sort.
|
|
16
|
+
* @param label - Label for error messages (e.g. "resource", "step").
|
|
17
|
+
* @returns Sorted items or error if cycle detected.
|
|
18
|
+
*/
|
|
19
|
+
export const topological_sort = (items, label = 'item') => {
|
|
20
|
+
// Build id -> item map
|
|
21
|
+
const item_map = new Map();
|
|
22
|
+
for (const item of items) {
|
|
23
|
+
if (item_map.has(item.id)) {
|
|
24
|
+
return { ok: false, error: `duplicate ${label} id: ${item.id}` };
|
|
25
|
+
}
|
|
26
|
+
item_map.set(item.id, item);
|
|
27
|
+
}
|
|
28
|
+
// Validate all dependencies exist and count dependents per item
|
|
29
|
+
// dependents_count[X] = how many items list X in their depends_on
|
|
30
|
+
const dependents_count = new Map();
|
|
31
|
+
for (const item of items) {
|
|
32
|
+
dependents_count.set(item.id, 0);
|
|
33
|
+
}
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
const deps = item.depends_on ?? [];
|
|
36
|
+
for (const dep of deps) {
|
|
37
|
+
if (!item_map.has(dep)) {
|
|
38
|
+
return { ok: false, error: `${label} "${item.id}" depends on unknown ${label} "${dep}"` };
|
|
39
|
+
}
|
|
40
|
+
dependents_count.set(dep, dependents_count.get(dep) + 1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Start from leaf items (nothing depends on them), work toward roots
|
|
44
|
+
const queue = [];
|
|
45
|
+
for (const [id, count] of dependents_count) {
|
|
46
|
+
if (count === 0) {
|
|
47
|
+
queue.push(id);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Process leaves first, then items whose dependents are all processed
|
|
51
|
+
const sorted = [];
|
|
52
|
+
const visited = new Set();
|
|
53
|
+
while (queue.length > 0) {
|
|
54
|
+
const id = queue.shift();
|
|
55
|
+
if (visited.has(id))
|
|
56
|
+
continue;
|
|
57
|
+
visited.add(id);
|
|
58
|
+
sorted.push(item_map.get(id));
|
|
59
|
+
// This item is processed — decrement its dependencies' dependent counts
|
|
60
|
+
const deps = item_map.get(id).depends_on ?? [];
|
|
61
|
+
for (const dep of deps) {
|
|
62
|
+
const new_count = dependents_count.get(dep) - 1;
|
|
63
|
+
dependents_count.set(dep, new_count);
|
|
64
|
+
if (new_count === 0) {
|
|
65
|
+
queue.push(dep);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Check for cycle
|
|
70
|
+
if (sorted.length !== items.length) {
|
|
71
|
+
const unvisited = items.filter((item) => !visited.has(item.id)).map((item) => item.id);
|
|
72
|
+
const cycle = find_cycle(item_map, unvisited);
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: `dependency cycle detected: ${cycle.join(' -> ')}`,
|
|
76
|
+
cycle,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// Reverse: leaves were processed first, but dependencies must come first in output
|
|
80
|
+
sorted.reverse();
|
|
81
|
+
return { ok: true, sorted };
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Find a cycle in the dependency graph starting from unvisited nodes.
|
|
85
|
+
* Used for error reporting when a cycle is detected.
|
|
86
|
+
*/
|
|
87
|
+
const find_cycle = (item_map, unvisited) => {
|
|
88
|
+
const unvisited_set = new Set(unvisited);
|
|
89
|
+
// DFS to find cycle
|
|
90
|
+
const path = [];
|
|
91
|
+
const in_path = new Set();
|
|
92
|
+
const dfs = (id) => {
|
|
93
|
+
if (in_path.has(id)) {
|
|
94
|
+
path.push(id);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (!unvisited_set.has(id))
|
|
98
|
+
return false;
|
|
99
|
+
in_path.add(id);
|
|
100
|
+
path.push(id);
|
|
101
|
+
const item = item_map.get(id);
|
|
102
|
+
const deps = item?.depends_on ?? [];
|
|
103
|
+
for (const dep of deps) {
|
|
104
|
+
if (dfs(dep))
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
in_path.delete(id);
|
|
108
|
+
path.pop();
|
|
109
|
+
return false;
|
|
110
|
+
};
|
|
111
|
+
if (unvisited.length > 0) {
|
|
112
|
+
dfs(unvisited[0]);
|
|
113
|
+
}
|
|
114
|
+
// Extract just the cycle portion
|
|
115
|
+
if (path.length > 0) {
|
|
116
|
+
const last = path[path.length - 1];
|
|
117
|
+
const cycle_start = path.indexOf(last);
|
|
118
|
+
if (cycle_start < path.length - 1) {
|
|
119
|
+
return path.slice(cycle_start);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return unvisited;
|
|
123
|
+
};
|
package/dist/source_json.d.ts
CHANGED
|
@@ -16,10 +16,10 @@ import { z } from 'zod';
|
|
|
16
16
|
export declare const DeclarationKind: z.ZodEnum<{
|
|
17
17
|
function: "function";
|
|
18
18
|
type: "type";
|
|
19
|
-
constructor: "constructor";
|
|
20
19
|
json: "json";
|
|
21
20
|
variable: "variable";
|
|
22
21
|
class: "class";
|
|
22
|
+
constructor: "constructor";
|
|
23
23
|
component: "component";
|
|
24
24
|
css: "css";
|
|
25
25
|
}>;
|
|
@@ -80,10 +80,10 @@ export declare const DeclarationJson: z.ZodObject<{
|
|
|
80
80
|
kind: z.ZodEnum<{
|
|
81
81
|
function: "function";
|
|
82
82
|
type: "type";
|
|
83
|
-
constructor: "constructor";
|
|
84
83
|
json: "json";
|
|
85
84
|
variable: "variable";
|
|
86
85
|
class: "class";
|
|
86
|
+
constructor: "constructor";
|
|
87
87
|
component: "component";
|
|
88
88
|
css: "css";
|
|
89
89
|
}>;
|
|
@@ -172,10 +172,10 @@ export declare const ModuleJson: z.ZodObject<{
|
|
|
172
172
|
kind: z.ZodEnum<{
|
|
173
173
|
function: "function";
|
|
174
174
|
type: "type";
|
|
175
|
-
constructor: "constructor";
|
|
176
175
|
json: "json";
|
|
177
176
|
variable: "variable";
|
|
178
177
|
class: "class";
|
|
178
|
+
constructor: "constructor";
|
|
179
179
|
component: "component";
|
|
180
180
|
css: "css";
|
|
181
181
|
}>;
|
|
@@ -279,10 +279,10 @@ export declare const SourceJson: z.ZodObject<{
|
|
|
279
279
|
kind: z.ZodEnum<{
|
|
280
280
|
function: "function";
|
|
281
281
|
type: "type";
|
|
282
|
-
constructor: "constructor";
|
|
283
282
|
json: "json";
|
|
284
283
|
variable: "variable";
|
|
285
284
|
class: "class";
|
|
285
|
+
constructor: "constructor";
|
|
286
286
|
component: "component";
|
|
287
287
|
css: "css";
|
|
288
288
|
}>;
|
package/dist/string.d.ts
CHANGED
|
@@ -70,4 +70,13 @@ export declare const pad_width: (str: string, target_width: number, align?: "lef
|
|
|
70
70
|
* @returns The edit distance between the strings
|
|
71
71
|
*/
|
|
72
72
|
export declare const levenshtein_distance: (a: string, b: string) => number;
|
|
73
|
+
/**
|
|
74
|
+
* Check if content appears to be binary.
|
|
75
|
+
*
|
|
76
|
+
* Checks for null bytes in the first 8KB of content.
|
|
77
|
+
*
|
|
78
|
+
* @param content - Content to check.
|
|
79
|
+
* @returns True if content appears to be binary.
|
|
80
|
+
*/
|
|
81
|
+
export declare const is_binary: (content: string) => boolean;
|
|
73
82
|
//# sourceMappingURL=string.d.ts.map
|
package/dist/string.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"string.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/string.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,eAAO,MAAM,QAAQ,GAAI,KAAK,MAAM,EAAE,WAAW,MAAM,EAAE,eAAc,KAAG,MAMzE,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,MAAM,EAAE,UAAU,MAAM,KAAG,MAG9D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS,GAAI,QAAQ,MAAM,EAAE,UAAU,MAAM,KAAG,MAG5D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,MAAM,EAAE,UAAU,MAAM,KAAG,MAK9D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,YAAY,GAAI,QAAQ,MAAM,EAAE,UAAU,MAAM,KAAG,MAK/D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,YAAY,GAAI,QAAQ,MAAM,EAAE,SAAS,MAAM,KAAG,MAG9D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,UAAU,GAAI,QAAQ,MAAM,EAAE,SAAS,MAAM,KAAG,MAG5D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,QAAQ,GAAI,KAAK,MAAM,KAAG,MAK1B,CAAC;AAEd;;GAEG;AACH,eAAO,MAAM,MAAM,GAAI,OAAO,MAAM,GAAG,SAAS,GAAG,IAAI,EAAE,eAAY,KAAG,MAC9C,CAAC;AAE3B;;GAEG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,KAAG,MACI,CAAC;AAEnD;;GAEG;AACH,eAAO,MAAM,UAAU,GAAI,KAAK,MAAM,KAAG,MAAsD,CAAC;AAEhG;;;;GAIG;AACH,eAAO,MAAM,SAAS,GAAI,OAAO,OAAO,KAAG,MACwC,CAAC;AAEpF;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,MAAM,KAAG,MAuClD,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS,GACrB,KAAK,MAAM,EACX,cAAc,MAAM,EACpB,QAAO,MAAM,GAAG,OAAgB,KAC9B,MAQF,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,MAmC3D,CAAC"}
|
|
1
|
+
{"version":3,"file":"string.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/string.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,eAAO,MAAM,QAAQ,GAAI,KAAK,MAAM,EAAE,WAAW,MAAM,EAAE,eAAc,KAAG,MAMzE,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,MAAM,EAAE,UAAU,MAAM,KAAG,MAG9D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS,GAAI,QAAQ,MAAM,EAAE,UAAU,MAAM,KAAG,MAG5D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,MAAM,EAAE,UAAU,MAAM,KAAG,MAK9D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,YAAY,GAAI,QAAQ,MAAM,EAAE,UAAU,MAAM,KAAG,MAK/D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,YAAY,GAAI,QAAQ,MAAM,EAAE,SAAS,MAAM,KAAG,MAG9D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,UAAU,GAAI,QAAQ,MAAM,EAAE,SAAS,MAAM,KAAG,MAG5D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,QAAQ,GAAI,KAAK,MAAM,KAAG,MAK1B,CAAC;AAEd;;GAEG;AACH,eAAO,MAAM,MAAM,GAAI,OAAO,MAAM,GAAG,SAAS,GAAG,IAAI,EAAE,eAAY,KAAG,MAC9C,CAAC;AAE3B;;GAEG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,KAAG,MACI,CAAC;AAEnD;;GAEG;AACH,eAAO,MAAM,UAAU,GAAI,KAAK,MAAM,KAAG,MAAsD,CAAC;AAEhG;;;;GAIG;AACH,eAAO,MAAM,SAAS,GAAI,OAAO,OAAO,KAAG,MACwC,CAAC;AAEpF;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,MAAM,KAAG,MAuClD,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,SAAS,GACrB,KAAK,MAAM,EACX,cAAc,MAAM,EACpB,QAAO,MAAM,GAAG,OAAgB,KAC9B,MAQF,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,MAmC3D,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS,GAAI,SAAS,MAAM,KAAG,OAG3C,CAAC"}
|
package/dist/string.js
CHANGED
|
@@ -191,3 +191,15 @@ export const levenshtein_distance = (a, b) => {
|
|
|
191
191
|
}
|
|
192
192
|
return prev[short_len];
|
|
193
193
|
};
|
|
194
|
+
/**
|
|
195
|
+
* Check if content appears to be binary.
|
|
196
|
+
*
|
|
197
|
+
* Checks for null bytes in the first 8KB of content.
|
|
198
|
+
*
|
|
199
|
+
* @param content - Content to check.
|
|
200
|
+
* @returns True if content appears to be binary.
|
|
201
|
+
*/
|
|
202
|
+
export const is_binary = (content) => {
|
|
203
|
+
const sample = content.slice(0, 8192);
|
|
204
|
+
return sample.includes('\0');
|
|
205
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzdev/fuz_util",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.4",
|
|
4
4
|
"description": "utility belt for JS",
|
|
5
5
|
"glyph": "🦕",
|
|
6
6
|
"logo": "logo.svg",
|
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@changesets/changelog-git": "^0.2.1",
|
|
64
|
-
"@fuzdev/fuz_code": "^0.
|
|
65
|
-
"@fuzdev/fuz_css": "^0.
|
|
66
|
-
"@fuzdev/fuz_ui": "^0.
|
|
64
|
+
"@fuzdev/fuz_code": "^0.41.0",
|
|
65
|
+
"@fuzdev/fuz_css": "^0.47.0",
|
|
66
|
+
"@fuzdev/fuz_ui": "^0.181.1",
|
|
67
67
|
"@ryanatkn/eslint-config": "^0.9.0",
|
|
68
|
-
"@ryanatkn/gro": "^0.
|
|
68
|
+
"@ryanatkn/gro": "^0.190.0",
|
|
69
69
|
"@sveltejs/adapter-static": "^3.0.10",
|
|
70
70
|
"@sveltejs/kit": "^2.50.1",
|
|
71
71
|
"@sveltejs/package": "^2.5.7",
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
"fast-deep-equal": "^3.1.3",
|
|
80
80
|
"prettier": "^3.7.4",
|
|
81
81
|
"prettier-plugin-svelte": "^3.4.1",
|
|
82
|
-
"svelte": "^5.
|
|
83
|
-
"svelte-check": "^4.3.
|
|
82
|
+
"svelte": "^5.49.1",
|
|
83
|
+
"svelte-check": "^4.3.6",
|
|
84
84
|
"tslib": "^2.8.1",
|
|
85
85
|
"typescript": "^5.9.3",
|
|
86
86
|
"typescript-eslint": "^8.48.1",
|
package/src/lib/async.ts
CHANGED
|
@@ -249,3 +249,36 @@ export const map_concurrent_settled = async <T, R>(
|
|
|
249
249
|
run_next();
|
|
250
250
|
});
|
|
251
251
|
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Async semaphore for concurrency limiting.
|
|
255
|
+
*
|
|
256
|
+
* With `Infinity` permits, `acquire()` always resolves immediately.
|
|
257
|
+
*/
|
|
258
|
+
export class AsyncSemaphore {
|
|
259
|
+
#permits: number;
|
|
260
|
+
#waiters: Array<() => void> = [];
|
|
261
|
+
|
|
262
|
+
constructor(permits: number) {
|
|
263
|
+
this.#permits = permits;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async acquire(): Promise<void> {
|
|
267
|
+
if (this.#permits > 0) {
|
|
268
|
+
this.#permits--;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
return new Promise<void>((resolve) => {
|
|
272
|
+
this.#waiters.push(resolve);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
release(): void {
|
|
277
|
+
const next = this.#waiters.shift();
|
|
278
|
+
if (next) {
|
|
279
|
+
next();
|
|
280
|
+
} else {
|
|
281
|
+
this.#permits++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
package/src/lib/dag.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic concurrent DAG executor.
|
|
3
|
+
*
|
|
4
|
+
* Executes nodes in a dependency graph with configurable concurrency,
|
|
5
|
+
* failure handling, and skip semantics. Nodes without dependency edges
|
|
6
|
+
* run in parallel (up to max_concurrency). Dependencies are respected
|
|
7
|
+
* via per-node deferreds.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {AsyncSemaphore, create_deferred, type Deferred} from './async.js';
|
|
13
|
+
import {topological_sort, type Sortable} from './sort.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimum shape for a DAG node.
|
|
17
|
+
*/
|
|
18
|
+
export interface DagNode extends Sortable {
|
|
19
|
+
id: string;
|
|
20
|
+
depends_on?: Array<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Options for running a DAG.
|
|
25
|
+
*/
|
|
26
|
+
export interface DagOptions<T extends DagNode> {
|
|
27
|
+
/** Nodes to execute. */
|
|
28
|
+
nodes: Array<T>;
|
|
29
|
+
/** Execute a node. Throw on failure. */
|
|
30
|
+
execute: (node: T) => Promise<void>;
|
|
31
|
+
/** Called after a node fails. For observability — the error is already recorded. */
|
|
32
|
+
on_error?: (node: T, error: Error) => Promise<void>;
|
|
33
|
+
/** Called when a node is skipped (pre-skip or dependency failure). */
|
|
34
|
+
on_skip?: (node: T, reason: string) => Promise<void>;
|
|
35
|
+
/** Return true to skip a node without executing. Dependents still proceed. */
|
|
36
|
+
should_skip?: (node: T) => boolean;
|
|
37
|
+
/** Maximum concurrent executions. Default: Infinity. */
|
|
38
|
+
max_concurrency?: number;
|
|
39
|
+
/** Stop starting new nodes on first failure. Default: true. */
|
|
40
|
+
stop_on_failure?: boolean;
|
|
41
|
+
/** Skip internal graph validation (caller already validated). */
|
|
42
|
+
skip_validation?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Result for a single node.
|
|
47
|
+
*/
|
|
48
|
+
export interface DagNodeResult {
|
|
49
|
+
id: string;
|
|
50
|
+
status: 'completed' | 'failed' | 'skipped';
|
|
51
|
+
error?: string;
|
|
52
|
+
duration_ms: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Result of a DAG execution.
|
|
57
|
+
*/
|
|
58
|
+
export interface DagResult {
|
|
59
|
+
/** Whether all executed nodes succeeded. */
|
|
60
|
+
success: boolean;
|
|
61
|
+
/** Per-node results. */
|
|
62
|
+
results: Map<string, DagNodeResult>;
|
|
63
|
+
/** Number of nodes that completed successfully. */
|
|
64
|
+
completed: number;
|
|
65
|
+
/** Number of nodes that failed. */
|
|
66
|
+
failed: number;
|
|
67
|
+
/** Number of nodes that were skipped. */
|
|
68
|
+
skipped: number;
|
|
69
|
+
/** Total execution time in milliseconds. */
|
|
70
|
+
duration_ms: number;
|
|
71
|
+
/** Error message if any nodes failed. */
|
|
72
|
+
error?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Execute nodes in a dependency graph concurrently.
|
|
77
|
+
*
|
|
78
|
+
* Independent nodes (no unmet dependencies) run in parallel up to
|
|
79
|
+
* `max_concurrency`. When a node completes, its dependents become
|
|
80
|
+
* eligible to start. Failure cascading and stop-on-failure are handled
|
|
81
|
+
* per the options.
|
|
82
|
+
*
|
|
83
|
+
* @param options - DAG execution options.
|
|
84
|
+
* @returns Aggregated result with per-node details.
|
|
85
|
+
*/
|
|
86
|
+
export const run_dag = async <T extends DagNode>(options: DagOptions<T>): Promise<DagResult> => {
|
|
87
|
+
const {
|
|
88
|
+
nodes,
|
|
89
|
+
execute,
|
|
90
|
+
on_error,
|
|
91
|
+
on_skip,
|
|
92
|
+
should_skip,
|
|
93
|
+
max_concurrency = Infinity,
|
|
94
|
+
stop_on_failure = true,
|
|
95
|
+
skip_validation = false,
|
|
96
|
+
} = options;
|
|
97
|
+
|
|
98
|
+
const start_time = Date.now();
|
|
99
|
+
|
|
100
|
+
// Empty graph
|
|
101
|
+
if (nodes.length === 0) {
|
|
102
|
+
return {
|
|
103
|
+
success: true,
|
|
104
|
+
results: new Map(),
|
|
105
|
+
completed: 0,
|
|
106
|
+
failed: 0,
|
|
107
|
+
skipped: 0,
|
|
108
|
+
duration_ms: 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Validate graph (cycle detection, duplicate IDs, missing deps)
|
|
113
|
+
if (!skip_validation) {
|
|
114
|
+
const sort_result = topological_sort(nodes, 'node');
|
|
115
|
+
if (!sort_result.ok) {
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
results: new Map(),
|
|
119
|
+
completed: 0,
|
|
120
|
+
failed: 0,
|
|
121
|
+
skipped: 0,
|
|
122
|
+
duration_ms: Date.now() - start_time,
|
|
123
|
+
error: sort_result.error,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Build deferreds and tracking maps
|
|
129
|
+
const deferreds: Map<string, Deferred<void>> = new Map();
|
|
130
|
+
const outcomes: Map<string, 'ok' | 'fail'> = new Map();
|
|
131
|
+
const results: Map<string, DagNodeResult> = new Map();
|
|
132
|
+
|
|
133
|
+
for (const node of nodes) {
|
|
134
|
+
deferreds.set(node.id, create_deferred());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let stopping = false;
|
|
138
|
+
const semaphore = new AsyncSemaphore(max_concurrency);
|
|
139
|
+
|
|
140
|
+
// Skip a node, record outcome, notify dependents
|
|
141
|
+
const skip_node = async (node: T, outcome: 'ok' | 'fail', reason: string): Promise<void> => {
|
|
142
|
+
outcomes.set(node.id, outcome);
|
|
143
|
+
results.set(node.id, {id: node.id, status: 'skipped', duration_ms: 0});
|
|
144
|
+
if (on_skip) await on_skip(node, reason);
|
|
145
|
+
deferreds.get(node.id)!.resolve();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Per-node async task
|
|
149
|
+
const run_node = async (node: T): Promise<void> => {
|
|
150
|
+
const deps = node.depends_on ?? [];
|
|
151
|
+
|
|
152
|
+
// Wait for all dependencies to resolve
|
|
153
|
+
if (deps.length > 0) {
|
|
154
|
+
await Promise.all(deps.map((d) => deferreds.get(d)!.promise));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Pre-skip check (e.g., pipeline step.skip or change.action === 'skip')
|
|
158
|
+
if (should_skip?.(node)) {
|
|
159
|
+
return skip_node(node, 'ok', 'pre-skipped');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if any dependency failed — skip this node too
|
|
163
|
+
if (deps.some((d) => outcomes.get(d) === 'fail')) {
|
|
164
|
+
return skip_node(node, 'fail', 'dependency failed');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check if we're stopping (some other node failed with stop_on_failure)
|
|
168
|
+
if (stopping) {
|
|
169
|
+
return skip_node(node, 'fail', 'stopped');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Acquire concurrency slot
|
|
173
|
+
await semaphore.acquire();
|
|
174
|
+
|
|
175
|
+
// Double-check stopping after acquiring slot
|
|
176
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
177
|
+
if (stopping) {
|
|
178
|
+
semaphore.release();
|
|
179
|
+
return skip_node(node, 'fail', 'stopped');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Execute
|
|
183
|
+
const exec_start = Date.now();
|
|
184
|
+
try {
|
|
185
|
+
await execute(node);
|
|
186
|
+
outcomes.set(node.id, 'ok');
|
|
187
|
+
results.set(node.id, {
|
|
188
|
+
id: node.id,
|
|
189
|
+
status: 'completed',
|
|
190
|
+
duration_ms: Date.now() - exec_start,
|
|
191
|
+
});
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
194
|
+
outcomes.set(node.id, 'fail');
|
|
195
|
+
results.set(node.id, {
|
|
196
|
+
id: node.id,
|
|
197
|
+
status: 'failed',
|
|
198
|
+
error: error.message,
|
|
199
|
+
duration_ms: Date.now() - exec_start,
|
|
200
|
+
});
|
|
201
|
+
if (stop_on_failure) stopping = true;
|
|
202
|
+
if (on_error) await on_error(node, error);
|
|
203
|
+
} finally {
|
|
204
|
+
semaphore.release();
|
|
205
|
+
deferreds.get(node.id)!.resolve();
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Launch all nodes — they naturally wait for their deps via deferreds
|
|
210
|
+
await Promise.all(nodes.map(run_node));
|
|
211
|
+
|
|
212
|
+
// Aggregate results
|
|
213
|
+
let completed = 0;
|
|
214
|
+
let failed = 0;
|
|
215
|
+
let skipped = 0;
|
|
216
|
+
for (const result of results.values()) {
|
|
217
|
+
switch (result.status) {
|
|
218
|
+
case 'completed':
|
|
219
|
+
completed++;
|
|
220
|
+
break;
|
|
221
|
+
case 'failed':
|
|
222
|
+
failed++;
|
|
223
|
+
break;
|
|
224
|
+
case 'skipped':
|
|
225
|
+
skipped++;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const success = failed === 0;
|
|
231
|
+
return {
|
|
232
|
+
success,
|
|
233
|
+
results,
|
|
234
|
+
completed,
|
|
235
|
+
failed,
|
|
236
|
+
skipped,
|
|
237
|
+
duration_ms: Date.now() - start_time,
|
|
238
|
+
error: success ? undefined : `${failed} node(s) failed`,
|
|
239
|
+
};
|
|
240
|
+
};
|