@openfn/project 0.4.1 → 0.5.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/README.md +81 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +152 -70
- package/package.json +3 -1
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
## openfn/project
|
|
2
|
+
|
|
3
|
+
A package to track, parse and serialize OpenFn project definitions.
|
|
4
|
+
|
|
5
|
+
A PROJECT is defined as a set of connected workflows with a single billing account, like a project in the app.
|
|
6
|
+
|
|
7
|
+
A single Project can be Checked Out to disk at a time, meaning its source workflows and expressions will be expanded nicely onto the file system.
|
|
8
|
+
|
|
9
|
+
A Workspace is a set of related Projects , including a Project and its associated Sandboxes, or a Project deployed to apps in multiple web domains
|
|
10
|
+
|
|
11
|
+
### Serializing and Parsing
|
|
12
|
+
|
|
13
|
+
The main idea of Projects is that a Project represents a set of OpenFn workflows defined in any format and present a standard JS-friendly interface to manipulate and reason about them.
|
|
14
|
+
|
|
15
|
+
The from/to serializers are designed to support the following formats:
|
|
16
|
+
|
|
17
|
+
- Projects expanded to the file system (through CLI or hand-written)
|
|
18
|
+
- v1 JSON state files generated by `openfn pull`
|
|
19
|
+
- v2 Project files (basically v1 state with some extra props)
|
|
20
|
+
|
|
21
|
+
Serializers and parsers also support JSON and YAML formats interchangeably.
|
|
22
|
+
|
|
23
|
+
### Project & Workflow Generation
|
|
24
|
+
|
|
25
|
+
`project` exports utility functions to generate Projects and Workflows from a simple syntax. This is useful for testing.
|
|
26
|
+
|
|
27
|
+
Really it's a Workflow generator, but you can have it wrapped in a Project if you like.
|
|
28
|
+
|
|
29
|
+
Use it like this:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
import { generateProject, generateWorkflow } from '@openfn/project'
|
|
33
|
+
import type { Project, Workflow } from '@openfn/project'
|
|
34
|
+
|
|
35
|
+
const proj: Project = generateProject('my-project', ['a-b b-c'])
|
|
36
|
+
const wfL Workflow = generateWorkflow('a-b b-c')
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Project generation uses a simple string language to represent a workflow structure:
|
|
40
|
+
|
|
41
|
+
Define nodes in pairs seperated by a dash (no whitespace)
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
a-b # parent-child
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For multiple children, define multiple pairs:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
a-b
|
|
51
|
+
a-c
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
You can set properties on the workflow itself - probably the name, with `@attributes`
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
@name my-cool-workflow
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
You can also set properties on a node by putting comma seperated key-value pairs in brackets
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
a(adaptor=http)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
You can use quotes to include spaces and brackets in a property value - great for expressions:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
a(expression="fn(s => s)")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
You can comment inside the string with `#`, which is a basic single-line comment
|
|
73
|
+
|
|
74
|
+
Reference:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
# Comments behind hashes
|
|
78
|
+
@attribute-name attribute-value
|
|
79
|
+
parent(propName=propValue,x=y)-child
|
|
80
|
+
a-b # can comment here to
|
|
81
|
+
```
|
package/dist/index.d.ts
CHANGED
|
@@ -116,12 +116,13 @@ type GenerateWorkflowOptions = {
|
|
|
116
116
|
name: string;
|
|
117
117
|
uuidSeed: number;
|
|
118
118
|
openfnUuid: boolean;
|
|
119
|
+
printErrors: boolean;
|
|
119
120
|
};
|
|
120
121
|
/**
|
|
121
122
|
* Generate a Workflow from a simple text based representation
|
|
122
|
-
*
|
|
123
|
-
* eg, ['a-b', 'b-c']
|
|
123
|
+
* eg, `a-b b-c a-c`
|
|
124
124
|
*/
|
|
125
|
-
declare function generateWorkflow(def: string
|
|
125
|
+
declare function generateWorkflow(def: string, options?: Partial<GenerateWorkflowOptions>): Workflow;
|
|
126
|
+
declare function generateProject(name: string, workflowDefs: string[], options: Partial<GenerateWorkflowOptions>): Project;
|
|
126
127
|
|
|
127
|
-
export { Workspace, Project as default, generateWorkflow
|
|
128
|
+
export { Workspace, Project as default, generateProject, generateWorkflow, jsonToYaml, yamlToJson };
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,11 @@ var __export = (target, all) => {
|
|
|
4
4
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
5
5
|
};
|
|
6
6
|
|
|
7
|
+
// src/util/slugify.ts
|
|
8
|
+
function slugify(text) {
|
|
9
|
+
return text?.replace(/\W/g, " ").trim().replace(/\s+/g, "-").toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
// src/Workflow.ts
|
|
8
13
|
var clone = (obj) => JSON.parse(JSON.stringify(obj));
|
|
9
14
|
var Workflow = class {
|
|
@@ -26,8 +31,8 @@ var Workflow = class {
|
|
|
26
31
|
};
|
|
27
32
|
this.workflow = clone(workflow);
|
|
28
33
|
const { id, name, openfn, steps, ...options } = workflow;
|
|
29
|
-
this.id = id;
|
|
30
|
-
this.name = name;
|
|
34
|
+
this.id = id ?? slugify(name);
|
|
35
|
+
this.name = name ?? id;
|
|
31
36
|
this.openfn = openfn;
|
|
32
37
|
this.options = options;
|
|
33
38
|
this.#buildIndex();
|
|
@@ -262,16 +267,16 @@ import nodepath from "path";
|
|
|
262
267
|
var stringify = (json) => JSON.stringify(json, null, 2);
|
|
263
268
|
function to_fs_default(project) {
|
|
264
269
|
const files = {};
|
|
265
|
-
const { path:
|
|
266
|
-
files[
|
|
270
|
+
const { path: path4, content } = extractRepoConfig(project);
|
|
271
|
+
files[path4] = content;
|
|
267
272
|
for (const wf of project.workflows) {
|
|
268
|
-
const { path:
|
|
269
|
-
files[
|
|
273
|
+
const { path: path5, content: content2 } = extractWorkflow(project, wf.id);
|
|
274
|
+
files[path5] = content2;
|
|
270
275
|
for (const s of wf.steps) {
|
|
271
276
|
const result = extractStep(project, wf.id, s.id);
|
|
272
277
|
if (result) {
|
|
273
|
-
const { path:
|
|
274
|
-
files[
|
|
278
|
+
const { path: path6, content: content3 } = result;
|
|
279
|
+
files[path6] = content3;
|
|
275
280
|
}
|
|
276
281
|
}
|
|
277
282
|
}
|
|
@@ -284,7 +289,7 @@ var extractWorkflow = (project, workflowId2) => {
|
|
|
284
289
|
throw new Error(`workflow not found: ${workflowId2}`);
|
|
285
290
|
}
|
|
286
291
|
const root = project.repo?.workflowRoot ?? "workflows/";
|
|
287
|
-
const
|
|
292
|
+
const path4 = nodepath.join(root, workflow.id, workflow.id);
|
|
288
293
|
const wf = {
|
|
289
294
|
id: workflow.id,
|
|
290
295
|
name: workflow.name,
|
|
@@ -299,7 +304,7 @@ var extractWorkflow = (project, workflowId2) => {
|
|
|
299
304
|
return mapped;
|
|
300
305
|
})
|
|
301
306
|
};
|
|
302
|
-
return handleOutput(wf,
|
|
307
|
+
return handleOutput(wf, path4, format);
|
|
303
308
|
};
|
|
304
309
|
var extractStep = (project, workflowId2, stepId) => {
|
|
305
310
|
const workflow = project.getWorkflow(workflowId2);
|
|
@@ -312,9 +317,9 @@ var extractStep = (project, workflowId2, stepId) => {
|
|
|
312
317
|
}
|
|
313
318
|
if (step.expression) {
|
|
314
319
|
const root = project.config?.workflowRoot ?? "workflows/";
|
|
315
|
-
const
|
|
320
|
+
const path4 = nodepath.join(root, `${workflow.id}/${step.id}.js`);
|
|
316
321
|
const content = step.expression;
|
|
317
|
-
return { path:
|
|
322
|
+
return { path: path4, content };
|
|
318
323
|
}
|
|
319
324
|
};
|
|
320
325
|
var extractRepoConfig = (project) => {
|
|
@@ -327,7 +332,7 @@ var extractRepoConfig = (project) => {
|
|
|
327
332
|
return handleOutput(config, "openfn", format);
|
|
328
333
|
};
|
|
329
334
|
var handleOutput = (data, filePath, format) => {
|
|
330
|
-
const
|
|
335
|
+
const path4 = `${filePath}.${format}`;
|
|
331
336
|
let content;
|
|
332
337
|
if (format === "json") {
|
|
333
338
|
content = stringify(data, null, 2);
|
|
@@ -336,11 +341,11 @@ var handleOutput = (data, filePath, format) => {
|
|
|
336
341
|
} else {
|
|
337
342
|
throw new Error(`Unrecognised format: ${format}`);
|
|
338
343
|
}
|
|
339
|
-
return { path:
|
|
344
|
+
return { path: path4, content };
|
|
340
345
|
};
|
|
341
346
|
|
|
342
347
|
// src/parse/from-app-state.ts
|
|
343
|
-
function
|
|
348
|
+
function slugify2(text) {
|
|
344
349
|
return text.replace(/\W/g, " ").trim().replace(/\s+/g, "-").toLowerCase();
|
|
345
350
|
}
|
|
346
351
|
var from_app_state_default = (state, config) => {
|
|
@@ -401,7 +406,7 @@ var mapTriggerEdgeCondition = (edge) => {
|
|
|
401
406
|
var mapWorkflow2 = (workflow) => {
|
|
402
407
|
const { jobs, edges, triggers, name, ...remoteProps } = workflow;
|
|
403
408
|
const mapped = {
|
|
404
|
-
id:
|
|
409
|
+
id: slugify2(workflow.name),
|
|
405
410
|
name: workflow.name,
|
|
406
411
|
steps: [],
|
|
407
412
|
openfn: renameKeys(remoteProps, { id: "uuid" })
|
|
@@ -420,7 +425,7 @@ var mapWorkflow2 = (workflow) => {
|
|
|
420
425
|
if (!target) {
|
|
421
426
|
throw new Error(`Failed to find ${edge.target_job_id}`);
|
|
422
427
|
}
|
|
423
|
-
obj[
|
|
428
|
+
obj[slugify2(target.name)] = mapTriggerEdgeCondition(edge);
|
|
424
429
|
return obj;
|
|
425
430
|
}, {})
|
|
426
431
|
});
|
|
@@ -431,7 +436,7 @@ var mapWorkflow2 = (workflow) => {
|
|
|
431
436
|
);
|
|
432
437
|
const { body: expression, name: name2, adaptor, ...remoteProps2 } = step;
|
|
433
438
|
const s = {
|
|
434
|
-
id:
|
|
439
|
+
id: slugify2(name2),
|
|
435
440
|
name: name2,
|
|
436
441
|
expression,
|
|
437
442
|
adaptor,
|
|
@@ -440,7 +445,7 @@ var mapWorkflow2 = (workflow) => {
|
|
|
440
445
|
if (outboundEdges.length) {
|
|
441
446
|
s.next = outboundEdges.reduce((next, edge) => {
|
|
442
447
|
const target = jobs.find((j) => j.id === edge.target_job_id);
|
|
443
|
-
next[
|
|
448
|
+
next[slugify2(target.name)] = mapTriggerEdgeCondition(edge);
|
|
444
449
|
return next;
|
|
445
450
|
}, {});
|
|
446
451
|
}
|
|
@@ -1048,6 +1053,7 @@ var Project = class {
|
|
|
1048
1053
|
return getUuidForStep(this, workflow, stepId);
|
|
1049
1054
|
}
|
|
1050
1055
|
};
|
|
1056
|
+
var Project_default = Project;
|
|
1051
1057
|
|
|
1052
1058
|
// src/util/path-exists.ts
|
|
1053
1059
|
import fs2 from "fs";
|
|
@@ -1119,67 +1125,142 @@ var Workspace = class {
|
|
|
1119
1125
|
}
|
|
1120
1126
|
};
|
|
1121
1127
|
|
|
1122
|
-
// src/gen/
|
|
1128
|
+
// src/gen/generator.ts
|
|
1123
1129
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
nodes[from].next[to] = edge(`${from}-${to}`);
|
|
1130
|
+
import path3 from "node:path";
|
|
1131
|
+
import { readFileSync } from "node:fs";
|
|
1132
|
+
import { grammar } from "ohm-js";
|
|
1133
|
+
var parser;
|
|
1134
|
+
var initOperations = (options = {}) => {
|
|
1135
|
+
let nodes = {};
|
|
1136
|
+
const uuid = () => {
|
|
1137
|
+
return options.uuidSeed ? options.uuidSeed++ : randomUUID2();
|
|
1138
|
+
};
|
|
1139
|
+
const buildNode = (name) => {
|
|
1140
|
+
if (!nodes[name]) {
|
|
1141
|
+
nodes[name] = {
|
|
1142
|
+
name,
|
|
1143
|
+
id: slugify(name),
|
|
1144
|
+
openfn: {
|
|
1145
|
+
uuid: uuid()
|
|
1141
1146
|
}
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
return nodes[name];
|
|
1150
|
+
};
|
|
1151
|
+
const operations = {
|
|
1152
|
+
Workflow(attrs, pair) {
|
|
1153
|
+
pair.children.forEach((child) => child.buildWorkflow());
|
|
1154
|
+
const steps = Object.values(nodes);
|
|
1155
|
+
const attributes = attrs.children.map((c) => c.buildWorkflow()).reduce((obj, next) => {
|
|
1156
|
+
const [key, value] = next;
|
|
1157
|
+
obj[key] = value;
|
|
1158
|
+
return obj;
|
|
1159
|
+
}, {});
|
|
1160
|
+
return { ...attributes, steps };
|
|
1161
|
+
},
|
|
1162
|
+
comment(_a, _b) {
|
|
1163
|
+
return null;
|
|
1164
|
+
},
|
|
1165
|
+
attribute(_, name, _space, value) {
|
|
1166
|
+
return [name.sourceString, value.sourceString];
|
|
1167
|
+
},
|
|
1168
|
+
Pair(parent, edge, child) {
|
|
1169
|
+
const n1 = parent.buildWorkflow();
|
|
1170
|
+
const n2 = child.buildWorkflow();
|
|
1171
|
+
const e = edge.buildWorkflow();
|
|
1172
|
+
n1.next ??= {};
|
|
1173
|
+
n1.next[n2.name] = e;
|
|
1174
|
+
return [n1, n2];
|
|
1175
|
+
},
|
|
1176
|
+
// node could just be a node name, or a node with props
|
|
1177
|
+
// different results have different requirements
|
|
1178
|
+
// Not sure the best way to handle this, but this seems to work
|
|
1179
|
+
node(node) {
|
|
1180
|
+
if (node._node.ruleName === "node_name") {
|
|
1181
|
+
return buildNode(node.sourceString);
|
|
1142
1182
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1183
|
+
return node.buildWorkflow();
|
|
1184
|
+
},
|
|
1185
|
+
nodeWithProps(nameNode, props) {
|
|
1186
|
+
const name = nameNode.sourceString;
|
|
1187
|
+
const node = buildNode(name);
|
|
1188
|
+
props.buildWorkflow().forEach(([key, value]) => {
|
|
1189
|
+
nodes[name][key] = value;
|
|
1190
|
+
});
|
|
1191
|
+
return node;
|
|
1192
|
+
},
|
|
1193
|
+
node_name(n) {
|
|
1194
|
+
return n.sourceString;
|
|
1195
|
+
},
|
|
1196
|
+
props(_lbr, props, _rbr) {
|
|
1197
|
+
return props.asIteration().children.map((c) => c.buildWorkflow());
|
|
1198
|
+
},
|
|
1199
|
+
prop(key, _op, value) {
|
|
1200
|
+
if (value._iter) {
|
|
1201
|
+
console.log(">>>> ITER");
|
|
1147
1202
|
}
|
|
1148
|
-
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1203
|
+
return [key.sourceString, value.buildWorkflow()];
|
|
1204
|
+
},
|
|
1205
|
+
// Bit flaky - we need this to handle quoted props
|
|
1206
|
+
_iter(...items) {
|
|
1207
|
+
return items.map((i) => i.buildWorkflow()).join("");
|
|
1208
|
+
},
|
|
1209
|
+
alnum(a) {
|
|
1210
|
+
return a.sourceString;
|
|
1211
|
+
},
|
|
1212
|
+
quotedProp(_left, value, _right) {
|
|
1213
|
+
return value.sourceString;
|
|
1214
|
+
},
|
|
1215
|
+
edge(_) {
|
|
1216
|
+
return {
|
|
1217
|
+
openfn: {
|
|
1218
|
+
uuid: uuid()
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1152
1221
|
}
|
|
1153
|
-
}
|
|
1222
|
+
};
|
|
1223
|
+
return operations;
|
|
1224
|
+
};
|
|
1225
|
+
var createParser = () => {
|
|
1226
|
+
const contents = readFileSync(path3.resolve("src/gen/workflow.ohm"), "utf-8");
|
|
1227
|
+
const parser2 = grammar(contents);
|
|
1154
1228
|
return {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1229
|
+
parse(str, options) {
|
|
1230
|
+
const { printErrors = true } = options;
|
|
1231
|
+
const semantics = parser2.createSemantics();
|
|
1232
|
+
semantics.addOperation("buildWorkflow", initOperations(options));
|
|
1233
|
+
const result = parser2.match(str);
|
|
1234
|
+
if (!result.succeeded()) {
|
|
1235
|
+
if (printErrors) {
|
|
1236
|
+
console.error(result.shortMessage);
|
|
1237
|
+
console.error(result.message);
|
|
1238
|
+
}
|
|
1239
|
+
throw new Error("Parsing failed!" + result.shortMessage);
|
|
1240
|
+
}
|
|
1241
|
+
const adaptor = semantics(result);
|
|
1242
|
+
return adaptor.buildWorkflow();
|
|
1243
|
+
}
|
|
1159
1244
|
};
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
openfn: { uuid: uuid(id) }
|
|
1165
|
-
};
|
|
1245
|
+
};
|
|
1246
|
+
function generateWorkflow(def, options = {}) {
|
|
1247
|
+
if (!parser) {
|
|
1248
|
+
parser = createParser();
|
|
1166
1249
|
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1250
|
+
const wf = new Workflow_default(parser.parse(def, options));
|
|
1251
|
+
if (options.openfnUuid) {
|
|
1252
|
+
wf.openfn = {
|
|
1253
|
+
uuid: randomUUID2()
|
|
1171
1254
|
};
|
|
1172
1255
|
}
|
|
1173
|
-
|
|
1174
|
-
const muuid = !isNaN(uuidSeed) ? ++uuidSeed : randomUUID2();
|
|
1175
|
-
ids.set(id, muuid);
|
|
1176
|
-
return muuid;
|
|
1177
|
-
}
|
|
1256
|
+
return wf;
|
|
1178
1257
|
}
|
|
1179
|
-
function
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1258
|
+
function generateProject(name, workflowDefs, options) {
|
|
1259
|
+
const workflows = workflowDefs.map((w) => generateWorkflow(w, options));
|
|
1260
|
+
return new Project_default({
|
|
1261
|
+
name,
|
|
1262
|
+
workflows
|
|
1263
|
+
});
|
|
1183
1264
|
}
|
|
1184
1265
|
|
|
1185
1266
|
// src/index.ts
|
|
@@ -1187,7 +1268,8 @@ var src_default = Project;
|
|
|
1187
1268
|
export {
|
|
1188
1269
|
Workspace,
|
|
1189
1270
|
src_default as default,
|
|
1190
|
-
|
|
1271
|
+
generateProject,
|
|
1272
|
+
generateWorkflow,
|
|
1191
1273
|
jsonToYaml,
|
|
1192
1274
|
yamlToJson
|
|
1193
1275
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfn/project",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Read, serialize, replicate and sync OpenFn projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"glob": "^11.0.2",
|
|
31
|
+
"lodash": "^4.17.21",
|
|
31
32
|
"lodash-es": "^4.17.21",
|
|
33
|
+
"ohm-js": "^17.2.1",
|
|
32
34
|
"yaml": "^2.2.2",
|
|
33
35
|
"@openfn/lexicon": "^1.2.3",
|
|
34
36
|
"@openfn/logger": "1.0.6"
|