@openfn/project 0.4.1 → 0.5.1
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 +26 -7
- package/dist/index.js +248 -89
- package/dist/workflow.ohm +38 -0
- package/package.json +5 -4
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 id, with `@attributes`
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
@id 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
|
@@ -15,7 +15,7 @@ declare class Workflow {
|
|
|
15
15
|
uuid: {};
|
|
16
16
|
id: {};
|
|
17
17
|
};
|
|
18
|
-
name
|
|
18
|
+
name?: string;
|
|
19
19
|
id: string;
|
|
20
20
|
openfn: OpenfnMeta;
|
|
21
21
|
constructor(workflow: l.Workflow);
|
|
@@ -31,6 +31,7 @@ declare class Workflow {
|
|
|
31
31
|
}) | undefined;
|
|
32
32
|
getUUID(id: any): string;
|
|
33
33
|
toJSON(): JSON.Object;
|
|
34
|
+
getUUIDMap(): Record<string, string>;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
type FromFsConfig = {
|
|
@@ -47,6 +48,10 @@ type FileFormats = 'yaml' | 'json';
|
|
|
47
48
|
interface OpenfnConfig {
|
|
48
49
|
name: string;
|
|
49
50
|
workflowRoot: string;
|
|
51
|
+
dirs: {
|
|
52
|
+
workflows: string;
|
|
53
|
+
projects: string;
|
|
54
|
+
};
|
|
50
55
|
formats: {
|
|
51
56
|
openfn: FileFormats;
|
|
52
57
|
project: FileFormats;
|
|
@@ -82,7 +87,9 @@ declare class Project {
|
|
|
82
87
|
collections: any;
|
|
83
88
|
static from(type: 'state', data: any, options: Partial<l.ProjectConfig>): Project;
|
|
84
89
|
static from(type: 'fs', options: FromFsConfig): Project;
|
|
85
|
-
static from(type: 'path', data:
|
|
90
|
+
static from(type: 'path', data: string, options?: {
|
|
91
|
+
config?: Partial<OpenfnConfig>;
|
|
92
|
+
}): Project;
|
|
86
93
|
static diff(a: Project, b: Project): void;
|
|
87
94
|
static merge(source: Project, target: Project, options: MergeProjectOptions): Project;
|
|
88
95
|
constructor(data: l.Project, repoConfig?: RepoOptions);
|
|
@@ -92,6 +99,13 @@ declare class Project {
|
|
|
92
99
|
getIdentifier(): string;
|
|
93
100
|
compare(proj: Project): void;
|
|
94
101
|
getUUID(workflow: string | Workflow, stepId: string, otherStep?: string): any;
|
|
102
|
+
/**
|
|
103
|
+
* Returns a map of ids:uuids for everything in the project
|
|
104
|
+
*/
|
|
105
|
+
getUUIDMap(options?: {
|
|
106
|
+
workflows: boolean;
|
|
107
|
+
project: false;
|
|
108
|
+
}): {};
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
declare class Workspace {
|
|
@@ -104,7 +118,7 @@ declare class Workspace {
|
|
|
104
118
|
get(id: string): Project | undefined;
|
|
105
119
|
getProjectPath(id: string): string | undefined;
|
|
106
120
|
getActiveProject(): Project | undefined;
|
|
107
|
-
getConfig(): OpenfnConfig
|
|
121
|
+
getConfig(): Partial<OpenfnConfig>;
|
|
108
122
|
get activeProjectId(): string | undefined;
|
|
109
123
|
get valid(): boolean;
|
|
110
124
|
}
|
|
@@ -115,13 +129,18 @@ declare function jsonToYaml(json: string | JSONObject): string;
|
|
|
115
129
|
type GenerateWorkflowOptions = {
|
|
116
130
|
name: string;
|
|
117
131
|
uuidSeed: number;
|
|
132
|
+
printErrors: boolean;
|
|
133
|
+
uuidMap?: Record<string, string>;
|
|
118
134
|
openfnUuid: boolean;
|
|
119
135
|
};
|
|
136
|
+
type GenerateProjectOptions = GenerateWorkflowOptions & {
|
|
137
|
+
uuidMap: Array<Record<string, string>>;
|
|
138
|
+
};
|
|
120
139
|
/**
|
|
121
140
|
* Generate a Workflow from a simple text based representation
|
|
122
|
-
*
|
|
123
|
-
* eg, ['a-b', 'b-c']
|
|
141
|
+
* eg, `a-b b-c a-c`
|
|
124
142
|
*/
|
|
125
|
-
declare function generateWorkflow(def: string
|
|
143
|
+
declare function generateWorkflow(def: string, options?: Partial<GenerateWorkflowOptions>): Workflow;
|
|
144
|
+
declare function generateProject(name: string, workflowDefs: string[], options?: Partial<GenerateProjectOptions>): Project;
|
|
126
145
|
|
|
127
|
-
export { Workspace, Project as default, generateWorkflow
|
|
146
|
+
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,15 @@ var Workflow = class {
|
|
|
26
31
|
};
|
|
27
32
|
this.workflow = clone(workflow);
|
|
28
33
|
const { id, name, openfn, steps, ...options } = workflow;
|
|
29
|
-
|
|
34
|
+
if (!(id || name)) {
|
|
35
|
+
throw new Error("A Workflow MUST have a name or id");
|
|
36
|
+
}
|
|
37
|
+
this.id = id ?? slugify(name);
|
|
30
38
|
this.name = name;
|
|
39
|
+
this.workflow.id = this.id;
|
|
40
|
+
if (name) {
|
|
41
|
+
this.workflow.name = this.name;
|
|
42
|
+
}
|
|
31
43
|
this.openfn = openfn;
|
|
32
44
|
this.options = options;
|
|
33
45
|
this.#buildIndex();
|
|
@@ -123,6 +135,9 @@ var Workflow = class {
|
|
|
123
135
|
toJSON() {
|
|
124
136
|
return this.workflow;
|
|
125
137
|
}
|
|
138
|
+
getUUIDMap() {
|
|
139
|
+
return this.index.uuid;
|
|
140
|
+
}
|
|
126
141
|
};
|
|
127
142
|
var Workflow_default = Workflow;
|
|
128
143
|
|
|
@@ -152,7 +167,7 @@ function to_json_default(project) {
|
|
|
152
167
|
}
|
|
153
168
|
|
|
154
169
|
// src/util/rename-keys.ts
|
|
155
|
-
function renameKeys(props, keyMap) {
|
|
170
|
+
function renameKeys(props = {}, keyMap) {
|
|
156
171
|
return Object.fromEntries(
|
|
157
172
|
Object.entries(props).map(([key, value]) => [
|
|
158
173
|
keyMap[key] ? keyMap[key] : key,
|
|
@@ -178,7 +193,7 @@ function jsonToYaml(json) {
|
|
|
178
193
|
// src/serialize/to-app-state.ts
|
|
179
194
|
import { randomUUID } from "node:crypto";
|
|
180
195
|
function to_app_state_default(project, options = {}) {
|
|
181
|
-
const { uuid: id, endpoint, env, ...rest } = project.openfn;
|
|
196
|
+
const { uuid: id, endpoint, env, ...rest } = project.openfn ?? {};
|
|
182
197
|
const state = {
|
|
183
198
|
id,
|
|
184
199
|
name: project.name,
|
|
@@ -199,9 +214,11 @@ var mapWorkflow = (workflow) => {
|
|
|
199
214
|
if (workflow instanceof Workflow_default) {
|
|
200
215
|
workflow = workflow.toJSON();
|
|
201
216
|
}
|
|
217
|
+
const { uuid, ...originalOpenfnProps } = workflow.openfn ?? {};
|
|
202
218
|
const wfState = {
|
|
219
|
+
...originalOpenfnProps,
|
|
220
|
+
id: workflow.openfn?.uuid ?? randomUUID(),
|
|
203
221
|
name: workflow.name,
|
|
204
|
-
...renameKeys(workflow.openfn, { uuid: "id" }),
|
|
205
222
|
jobs: [],
|
|
206
223
|
triggers: [],
|
|
207
224
|
edges: []
|
|
@@ -262,16 +279,16 @@ import nodepath from "path";
|
|
|
262
279
|
var stringify = (json) => JSON.stringify(json, null, 2);
|
|
263
280
|
function to_fs_default(project) {
|
|
264
281
|
const files = {};
|
|
265
|
-
const { path:
|
|
266
|
-
files[
|
|
282
|
+
const { path: path4, content } = extractRepoConfig(project);
|
|
283
|
+
files[path4] = content;
|
|
267
284
|
for (const wf of project.workflows) {
|
|
268
|
-
const { path:
|
|
269
|
-
files[
|
|
285
|
+
const { path: path5, content: content2 } = extractWorkflow(project, wf.id);
|
|
286
|
+
files[path5] = content2;
|
|
270
287
|
for (const s of wf.steps) {
|
|
271
288
|
const result = extractStep(project, wf.id, s.id);
|
|
272
289
|
if (result) {
|
|
273
|
-
const { path:
|
|
274
|
-
files[
|
|
290
|
+
const { path: path6, content: content3 } = result;
|
|
291
|
+
files[path6] = content3;
|
|
275
292
|
}
|
|
276
293
|
}
|
|
277
294
|
}
|
|
@@ -284,7 +301,7 @@ var extractWorkflow = (project, workflowId2) => {
|
|
|
284
301
|
throw new Error(`workflow not found: ${workflowId2}`);
|
|
285
302
|
}
|
|
286
303
|
const root = project.repo?.workflowRoot ?? "workflows/";
|
|
287
|
-
const
|
|
304
|
+
const path4 = nodepath.join(root, workflow.id, workflow.id);
|
|
288
305
|
const wf = {
|
|
289
306
|
id: workflow.id,
|
|
290
307
|
name: workflow.name,
|
|
@@ -299,7 +316,7 @@ var extractWorkflow = (project, workflowId2) => {
|
|
|
299
316
|
return mapped;
|
|
300
317
|
})
|
|
301
318
|
};
|
|
302
|
-
return handleOutput(wf,
|
|
319
|
+
return handleOutput(wf, path4, format);
|
|
303
320
|
};
|
|
304
321
|
var extractStep = (project, workflowId2, stepId) => {
|
|
305
322
|
const workflow = project.getWorkflow(workflowId2);
|
|
@@ -312,9 +329,9 @@ var extractStep = (project, workflowId2, stepId) => {
|
|
|
312
329
|
}
|
|
313
330
|
if (step.expression) {
|
|
314
331
|
const root = project.config?.workflowRoot ?? "workflows/";
|
|
315
|
-
const
|
|
332
|
+
const path4 = nodepath.join(root, `${workflow.id}/${step.id}.js`);
|
|
316
333
|
const content = step.expression;
|
|
317
|
-
return { path:
|
|
334
|
+
return { path: path4, content };
|
|
318
335
|
}
|
|
319
336
|
};
|
|
320
337
|
var extractRepoConfig = (project) => {
|
|
@@ -327,7 +344,7 @@ var extractRepoConfig = (project) => {
|
|
|
327
344
|
return handleOutput(config, "openfn", format);
|
|
328
345
|
};
|
|
329
346
|
var handleOutput = (data, filePath, format) => {
|
|
330
|
-
const
|
|
347
|
+
const path4 = `${filePath}.${format}`;
|
|
331
348
|
let content;
|
|
332
349
|
if (format === "json") {
|
|
333
350
|
content = stringify(data, null, 2);
|
|
@@ -336,18 +353,20 @@ var handleOutput = (data, filePath, format) => {
|
|
|
336
353
|
} else {
|
|
337
354
|
throw new Error(`Unrecognised format: ${format}`);
|
|
338
355
|
}
|
|
339
|
-
return { path:
|
|
356
|
+
return { path: path4, content };
|
|
340
357
|
};
|
|
341
358
|
|
|
342
359
|
// src/parse/from-app-state.ts
|
|
343
|
-
function
|
|
360
|
+
function slugify2(text) {
|
|
344
361
|
return text.replace(/\W/g, " ").trim().replace(/\s+/g, "-").toLowerCase();
|
|
345
362
|
}
|
|
346
363
|
var from_app_state_default = (state, config) => {
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
364
|
+
if (typeof state === "string") {
|
|
365
|
+
if (config?.format === "yaml") {
|
|
366
|
+
state = yamlToJson(state);
|
|
367
|
+
} else {
|
|
368
|
+
state = JSON.parse(state);
|
|
369
|
+
}
|
|
351
370
|
}
|
|
352
371
|
const {
|
|
353
372
|
id,
|
|
@@ -362,13 +381,11 @@ var from_app_state_default = (state, config) => {
|
|
|
362
381
|
} = state;
|
|
363
382
|
const proj = {
|
|
364
383
|
name,
|
|
365
|
-
// TODO do we need to slug this or anything?
|
|
366
384
|
description,
|
|
367
385
|
collections,
|
|
368
386
|
credentials,
|
|
369
387
|
options
|
|
370
388
|
};
|
|
371
|
-
const repoConfig = {};
|
|
372
389
|
proj.openfn = {
|
|
373
390
|
uuid: id,
|
|
374
391
|
endpoint: config.endpoint,
|
|
@@ -380,7 +397,7 @@ var from_app_state_default = (state, config) => {
|
|
|
380
397
|
fetched_at: config.fetchedAt
|
|
381
398
|
};
|
|
382
399
|
proj.workflows = state.workflows.map(mapWorkflow2);
|
|
383
|
-
return new Project(proj,
|
|
400
|
+
return new Project(proj, config?.repo);
|
|
384
401
|
};
|
|
385
402
|
var mapTriggerEdgeCondition = (edge) => {
|
|
386
403
|
const e = {
|
|
@@ -401,11 +418,13 @@ var mapTriggerEdgeCondition = (edge) => {
|
|
|
401
418
|
var mapWorkflow2 = (workflow) => {
|
|
402
419
|
const { jobs, edges, triggers, name, ...remoteProps } = workflow;
|
|
403
420
|
const mapped = {
|
|
404
|
-
id: slugify(workflow.name),
|
|
405
421
|
name: workflow.name,
|
|
406
422
|
steps: [],
|
|
407
423
|
openfn: renameKeys(remoteProps, { id: "uuid" })
|
|
408
424
|
};
|
|
425
|
+
if (workflow.name) {
|
|
426
|
+
mapped.id = slugify2(workflow.name);
|
|
427
|
+
}
|
|
409
428
|
workflow.triggers.forEach((trigger) => {
|
|
410
429
|
const { type, ...otherProps } = trigger;
|
|
411
430
|
const connectedEdges = edges.filter(
|
|
@@ -420,7 +439,7 @@ var mapWorkflow2 = (workflow) => {
|
|
|
420
439
|
if (!target) {
|
|
421
440
|
throw new Error(`Failed to find ${edge.target_job_id}`);
|
|
422
441
|
}
|
|
423
|
-
obj[
|
|
442
|
+
obj[slugify2(target.name)] = mapTriggerEdgeCondition(edge);
|
|
424
443
|
return obj;
|
|
425
444
|
}, {})
|
|
426
445
|
});
|
|
@@ -431,7 +450,7 @@ var mapWorkflow2 = (workflow) => {
|
|
|
431
450
|
);
|
|
432
451
|
const { body: expression, name: name2, adaptor, ...remoteProps2 } = step;
|
|
433
452
|
const s = {
|
|
434
|
-
id:
|
|
453
|
+
id: slugify2(name2),
|
|
435
454
|
name: name2,
|
|
436
455
|
expression,
|
|
437
456
|
adaptor,
|
|
@@ -440,7 +459,7 @@ var mapWorkflow2 = (workflow) => {
|
|
|
440
459
|
if (outboundEdges.length) {
|
|
441
460
|
s.next = outboundEdges.reduce((next, edge) => {
|
|
442
461
|
const target = jobs.find((j) => j.id === edge.target_job_id);
|
|
443
|
-
next[
|
|
462
|
+
next[slugify2(target.name)] = mapTriggerEdgeCondition(edge);
|
|
444
463
|
return next;
|
|
445
464
|
}, {});
|
|
446
465
|
}
|
|
@@ -449,6 +468,30 @@ var mapWorkflow2 = (workflow) => {
|
|
|
449
468
|
return mapped;
|
|
450
469
|
};
|
|
451
470
|
|
|
471
|
+
// src/parse/from-path.ts
|
|
472
|
+
import { extname } from "node:path";
|
|
473
|
+
import { readFile } from "node:fs/promises";
|
|
474
|
+
var from_path_default = async (path4, options = {}) => {
|
|
475
|
+
const ext = extname(path4).toLowerCase();
|
|
476
|
+
const source = await readFile(path4, "utf8");
|
|
477
|
+
const config = {
|
|
478
|
+
format: null,
|
|
479
|
+
repo: options.repo ?? options.config
|
|
480
|
+
// TMP
|
|
481
|
+
};
|
|
482
|
+
let state;
|
|
483
|
+
if (ext === ".json") {
|
|
484
|
+
config.format = "json";
|
|
485
|
+
state = JSON.parse(source);
|
|
486
|
+
} else if (ext.match(/(ya?ml)$/)) {
|
|
487
|
+
config.format = "yaml";
|
|
488
|
+
state = yamlToJson(source);
|
|
489
|
+
} else {
|
|
490
|
+
throw new Error(`Cannot load a project from a ${ext} file`);
|
|
491
|
+
}
|
|
492
|
+
return from_app_state_default(state, config);
|
|
493
|
+
};
|
|
494
|
+
|
|
452
495
|
// src/parse/from-fs.ts
|
|
453
496
|
import fs from "node:fs/promises";
|
|
454
497
|
import path from "node:path";
|
|
@@ -496,8 +539,12 @@ var parseProject = async (options = {}) => {
|
|
|
496
539
|
env: config.project?.env
|
|
497
540
|
});
|
|
498
541
|
try {
|
|
499
|
-
const format = config.formats
|
|
500
|
-
const statePath = path.join(
|
|
542
|
+
const format = config.formats?.project ?? config.formats?.projects ?? "yaml";
|
|
543
|
+
const statePath = path.join(
|
|
544
|
+
root,
|
|
545
|
+
config.dirs?.projects ?? ".projects",
|
|
546
|
+
`${identifier}.${format}`
|
|
547
|
+
);
|
|
501
548
|
const stateFile = await fs.readFile(statePath, "utf8");
|
|
502
549
|
state = from_app_state_default(stateFile, { format });
|
|
503
550
|
} catch (e) {
|
|
@@ -505,7 +552,8 @@ var parseProject = async (options = {}) => {
|
|
|
505
552
|
}
|
|
506
553
|
const { project: openfn, ...repo } = config;
|
|
507
554
|
proj.openfn = openfn;
|
|
508
|
-
|
|
555
|
+
proj.config = repo;
|
|
556
|
+
const workflowDir = config.workflowRoot ?? config.dirs?.workflows ?? "workflows";
|
|
509
557
|
const fileType = config.formats?.workflow ?? "yaml";
|
|
510
558
|
const pattern = `${root}/${workflowDir}/*/*.${fileType}`;
|
|
511
559
|
const candidateWfs = await glob(pattern, {
|
|
@@ -522,7 +570,6 @@ var parseProject = async (options = {}) => {
|
|
|
522
570
|
uuid: wfState.openfn?.uuid ?? null
|
|
523
571
|
// TODO do we need to transfer more stuff?
|
|
524
572
|
};
|
|
525
|
-
console.log("Loading workflow at ", filePath);
|
|
526
573
|
for (const step of wf.steps) {
|
|
527
574
|
if (step.expression && step.expression.endsWith(".js")) {
|
|
528
575
|
const dir = path.dirname(filePath);
|
|
@@ -560,7 +607,9 @@ var parseProject = async (options = {}) => {
|
|
|
560
607
|
var getUuidForStep = (project, workflow, stepId) => {
|
|
561
608
|
const wf = typeof workflow === "string" ? project.getWorkflow(workflow) : workflow;
|
|
562
609
|
if (!wf) {
|
|
563
|
-
throw new Error(
|
|
610
|
+
throw new Error(
|
|
611
|
+
`Workflow "${workflow}" not found in project ${project.id}`
|
|
612
|
+
);
|
|
564
613
|
}
|
|
565
614
|
for (const step of wf.steps) {
|
|
566
615
|
if (step.id === stepId) {
|
|
@@ -945,6 +994,7 @@ function merge(source, target, options) {
|
|
|
945
994
|
// src/Project.ts
|
|
946
995
|
var maybeCreateWorkflow = (wf) => wf instanceof Workflow_default ? wf : new Workflow_default(wf);
|
|
947
996
|
var setConfigDefaults = (config = {}) => ({
|
|
997
|
+
...config,
|
|
948
998
|
workflowRoot: config.workflowRoot ?? "workflows",
|
|
949
999
|
formats: {
|
|
950
1000
|
// TODO change these maybe
|
|
@@ -971,7 +1021,7 @@ var Project = class {
|
|
|
971
1021
|
meta;
|
|
972
1022
|
// this contains meta about the connected openfn project
|
|
973
1023
|
openfn;
|
|
974
|
-
//
|
|
1024
|
+
// workspace-wide configuration options
|
|
975
1025
|
// these should be shared across projects
|
|
976
1026
|
// and saved to an openfn.yaml file
|
|
977
1027
|
repo;
|
|
@@ -986,6 +1036,8 @@ var Project = class {
|
|
|
986
1036
|
return from_app_state_default(data, options);
|
|
987
1037
|
} else if (type === "fs") {
|
|
988
1038
|
return parseProject(data, options);
|
|
1039
|
+
} else if (type === "path") {
|
|
1040
|
+
return from_path_default(data, options);
|
|
989
1041
|
}
|
|
990
1042
|
throw new Error(`Didn't recognize type ${type}`);
|
|
991
1043
|
}
|
|
@@ -1047,7 +1099,21 @@ var Project = class {
|
|
|
1047
1099
|
}
|
|
1048
1100
|
return getUuidForStep(this, workflow, stepId);
|
|
1049
1101
|
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Returns a map of ids:uuids for everything in the project
|
|
1104
|
+
*/
|
|
1105
|
+
getUUIDMap(options = {}) {
|
|
1106
|
+
const result = {};
|
|
1107
|
+
for (const wf of this.workflows) {
|
|
1108
|
+
result[wf.id] = {
|
|
1109
|
+
self: wf.openfn?.uuid,
|
|
1110
|
+
children: wf.getUUIDMap()
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
return result;
|
|
1114
|
+
}
|
|
1050
1115
|
};
|
|
1116
|
+
var Project_default = Project;
|
|
1051
1117
|
|
|
1052
1118
|
// src/util/path-exists.ts
|
|
1053
1119
|
import fs2 from "fs";
|
|
@@ -1076,16 +1142,19 @@ var Workspace = class {
|
|
|
1076
1142
|
projectPaths = /* @__PURE__ */ new Map();
|
|
1077
1143
|
isValid = false;
|
|
1078
1144
|
constructor(workspacePath) {
|
|
1079
|
-
const projectsPath = path2.join(workspacePath, PROJECTS_DIRECTORY);
|
|
1080
1145
|
const openfnYamlPath = path2.join(workspacePath, OPENFN_YAML_FILE);
|
|
1081
1146
|
if (pathExists(openfnYamlPath, "file")) {
|
|
1082
1147
|
this.isValid = true;
|
|
1083
1148
|
const data = fs3.readFileSync(openfnYamlPath, "utf-8");
|
|
1084
1149
|
this.config = yamlToJson(data);
|
|
1085
1150
|
}
|
|
1151
|
+
const projectsPath = path2.join(
|
|
1152
|
+
workspacePath,
|
|
1153
|
+
this.config?.dirs?.projects ?? PROJECTS_DIRECTORY
|
|
1154
|
+
);
|
|
1086
1155
|
if (this.isValid && pathExists(projectsPath, "directory")) {
|
|
1087
1156
|
const stateFiles = fs3.readdirSync(projectsPath).filter(
|
|
1088
|
-
(fileName) => PROJECT_EXTENSIONS.includes(path2.extname(fileName))
|
|
1157
|
+
(fileName) => PROJECT_EXTENSIONS.includes(path2.extname(fileName)) && path2.parse(fileName).name !== "openfn"
|
|
1089
1158
|
);
|
|
1090
1159
|
this.projects = stateFiles.map((file) => {
|
|
1091
1160
|
const stateFilePath = path2.join(projectsPath, file);
|
|
@@ -1093,7 +1162,7 @@ var Workspace = class {
|
|
|
1093
1162
|
const project = from_app_state_default(data, { format: "yaml" });
|
|
1094
1163
|
this.projectPaths.set(project.name, stateFilePath);
|
|
1095
1164
|
return project;
|
|
1096
|
-
});
|
|
1165
|
+
}).filter((s) => s);
|
|
1097
1166
|
}
|
|
1098
1167
|
}
|
|
1099
1168
|
list() {
|
|
@@ -1119,67 +1188,156 @@ var Workspace = class {
|
|
|
1119
1188
|
}
|
|
1120
1189
|
};
|
|
1121
1190
|
|
|
1122
|
-
// src/gen/
|
|
1191
|
+
// src/gen/generator.ts
|
|
1123
1192
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1193
|
+
import path3 from "node:path";
|
|
1194
|
+
import { readFileSync } from "node:fs";
|
|
1195
|
+
import { grammar } from "ohm-js";
|
|
1196
|
+
var parser;
|
|
1197
|
+
var initOperations = (options = {}) => {
|
|
1198
|
+
let nodes = {};
|
|
1199
|
+
const uuidMap = options.uuidMap ?? {};
|
|
1200
|
+
const uuid = (id) => {
|
|
1201
|
+
if (id in uuidMap) {
|
|
1202
|
+
return uuidMap[id];
|
|
1203
|
+
}
|
|
1204
|
+
return options.uuidSeed ? options.uuidSeed++ : randomUUID2();
|
|
1205
|
+
};
|
|
1206
|
+
const buildNode = (name) => {
|
|
1207
|
+
if (!nodes[name]) {
|
|
1208
|
+
const id = slugify(name);
|
|
1209
|
+
nodes[name] = {
|
|
1210
|
+
name,
|
|
1211
|
+
id,
|
|
1212
|
+
openfn: {
|
|
1213
|
+
uuid: uuid(id)
|
|
1141
1214
|
}
|
|
1142
|
-
}
|
|
1143
|
-
} else {
|
|
1144
|
-
let props;
|
|
1145
|
-
if (to) {
|
|
1146
|
-
props = { next: { [to]: edge(`${from}-${to}`) } };
|
|
1147
|
-
}
|
|
1148
|
-
nodes[from] = node(from, props);
|
|
1215
|
+
};
|
|
1149
1216
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1217
|
+
return nodes[name];
|
|
1218
|
+
};
|
|
1219
|
+
const operations = {
|
|
1220
|
+
Workflow(attrs, pair) {
|
|
1221
|
+
pair.children.forEach((child) => child.buildWorkflow());
|
|
1222
|
+
const steps = Object.values(nodes);
|
|
1223
|
+
const attributes = attrs.children.map((c) => c.buildWorkflow()).reduce((obj, next) => {
|
|
1224
|
+
const [key, value] = next;
|
|
1225
|
+
obj[key] = value;
|
|
1226
|
+
return obj;
|
|
1227
|
+
}, {});
|
|
1228
|
+
return { ...attributes, steps };
|
|
1229
|
+
},
|
|
1230
|
+
comment(_a, _b) {
|
|
1231
|
+
return null;
|
|
1232
|
+
},
|
|
1233
|
+
attribute(_, name, _space, value) {
|
|
1234
|
+
return [name.sourceString, value.sourceString];
|
|
1235
|
+
},
|
|
1236
|
+
Pair(parent, edge, child) {
|
|
1237
|
+
const n1 = parent.buildWorkflow();
|
|
1238
|
+
const n2 = child.buildWorkflow();
|
|
1239
|
+
const e = edge.buildWorkflow();
|
|
1240
|
+
e.openfn.uuid = uuid(`${n1.id}-${n2.id}`);
|
|
1241
|
+
n1.next ??= {};
|
|
1242
|
+
n1.next[n2.name] = e;
|
|
1243
|
+
return [n1, n2];
|
|
1244
|
+
},
|
|
1245
|
+
// node could just be a node name, or a node with props
|
|
1246
|
+
// different results have different requirements
|
|
1247
|
+
// Not sure the best way to handle this, but this seems to work
|
|
1248
|
+
node(node) {
|
|
1249
|
+
if (node._node.ruleName === "node_name") {
|
|
1250
|
+
return buildNode(node.sourceString);
|
|
1251
|
+
}
|
|
1252
|
+
return node.buildWorkflow();
|
|
1253
|
+
},
|
|
1254
|
+
nodeWithProps(nameNode, props) {
|
|
1255
|
+
const name = nameNode.sourceString;
|
|
1256
|
+
const node = buildNode(name);
|
|
1257
|
+
props.buildWorkflow().forEach(([key, value]) => {
|
|
1258
|
+
nodes[name][key] = value;
|
|
1259
|
+
});
|
|
1260
|
+
return node;
|
|
1261
|
+
},
|
|
1262
|
+
node_name(n) {
|
|
1263
|
+
return n.sourceString;
|
|
1264
|
+
},
|
|
1265
|
+
props(_lbr, props, _rbr) {
|
|
1266
|
+
return props.asIteration().children.map((c) => c.buildWorkflow());
|
|
1267
|
+
},
|
|
1268
|
+
prop(key, _op, value) {
|
|
1269
|
+
return [key.sourceString, value.buildWorkflow()];
|
|
1270
|
+
},
|
|
1271
|
+
// Bit flaky - we need this to handle quoted props
|
|
1272
|
+
_iter(...items) {
|
|
1273
|
+
return items.map((i) => i.buildWorkflow()).join("");
|
|
1274
|
+
},
|
|
1275
|
+
alnum(a) {
|
|
1276
|
+
return a.sourceString;
|
|
1277
|
+
},
|
|
1278
|
+
quotedProp(_left, value, _right) {
|
|
1279
|
+
return value.sourceString;
|
|
1280
|
+
},
|
|
1281
|
+
edge(_) {
|
|
1282
|
+
return {
|
|
1283
|
+
openfn: {}
|
|
1284
|
+
};
|
|
1152
1285
|
}
|
|
1153
|
-
}
|
|
1286
|
+
};
|
|
1287
|
+
return operations;
|
|
1288
|
+
};
|
|
1289
|
+
var createParser = () => {
|
|
1290
|
+
const grammarPath = path3.resolve(import.meta.dirname, "workflow.ohm");
|
|
1291
|
+
const contents = readFileSync(grammarPath, "utf-8");
|
|
1292
|
+
const parser2 = grammar(contents);
|
|
1154
1293
|
return {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1294
|
+
parse(str, options) {
|
|
1295
|
+
const { printErrors = true } = options;
|
|
1296
|
+
const semantics = parser2.createSemantics();
|
|
1297
|
+
semantics.addOperation("buildWorkflow", initOperations(options));
|
|
1298
|
+
const result = parser2.match(str);
|
|
1299
|
+
if (!result.succeeded()) {
|
|
1300
|
+
if (printErrors) {
|
|
1301
|
+
console.error(result.shortMessage);
|
|
1302
|
+
console.error(result.message);
|
|
1303
|
+
}
|
|
1304
|
+
throw new Error("Parsing failed!" + result.shortMessage);
|
|
1305
|
+
}
|
|
1306
|
+
const adaptor = semantics(result);
|
|
1307
|
+
return adaptor.buildWorkflow();
|
|
1308
|
+
}
|
|
1159
1309
|
};
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
openfn: { uuid: uuid(id) }
|
|
1165
|
-
};
|
|
1310
|
+
};
|
|
1311
|
+
function generateWorkflow(def, options = {}) {
|
|
1312
|
+
if (!parser) {
|
|
1313
|
+
parser = createParser();
|
|
1166
1314
|
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
openfn: { uuid: uuid(id) }
|
|
1171
|
-
};
|
|
1315
|
+
const raw = parser.parse(def, options);
|
|
1316
|
+
if (!raw.name) {
|
|
1317
|
+
raw.name = "Workflow";
|
|
1172
1318
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
ids.set(id, muuid);
|
|
1176
|
-
return muuid;
|
|
1319
|
+
if (!raw.id) {
|
|
1320
|
+
raw.id = "workflow";
|
|
1177
1321
|
}
|
|
1322
|
+
if (options.openfnUuid) {
|
|
1323
|
+
raw.openfn ??= {};
|
|
1324
|
+
raw.openfn.uuid = randomUUID2();
|
|
1325
|
+
}
|
|
1326
|
+
const wf = new Workflow_default(raw);
|
|
1327
|
+
return wf;
|
|
1178
1328
|
}
|
|
1179
|
-
function
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1329
|
+
function generateProject(name, workflowDefs, options = {}) {
|
|
1330
|
+
const workflows = workflowDefs.map(
|
|
1331
|
+
(w, idx) => generateWorkflow(w, {
|
|
1332
|
+
...options,
|
|
1333
|
+
uuidMap: options.uuidMap && options.uuidMap[idx]
|
|
1334
|
+
})
|
|
1335
|
+
);
|
|
1336
|
+
return new Project_default({
|
|
1337
|
+
name,
|
|
1338
|
+
workflows,
|
|
1339
|
+
openfn: options.openfnUuid && { uuid: randomUUID2() }
|
|
1340
|
+
});
|
|
1183
1341
|
}
|
|
1184
1342
|
|
|
1185
1343
|
// src/index.ts
|
|
@@ -1187,7 +1345,8 @@ var src_default = Project;
|
|
|
1187
1345
|
export {
|
|
1188
1346
|
Workspace,
|
|
1189
1347
|
src_default as default,
|
|
1190
|
-
|
|
1348
|
+
generateProject,
|
|
1349
|
+
generateWorkflow,
|
|
1191
1350
|
jsonToYaml,
|
|
1192
1351
|
yamlToJson
|
|
1193
1352
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ohm grammar for interpreting our Workflow syntax
|
|
3
|
+
* https://ohmjs.org/docs/intro
|
|
4
|
+
*/
|
|
5
|
+
Workflow {
|
|
6
|
+
Workflow = attribute* Pair*
|
|
7
|
+
|
|
8
|
+
attribute = "@" attr_name space attr_name
|
|
9
|
+
|
|
10
|
+
attr_name = (alnum | "_" | "-")+
|
|
11
|
+
|
|
12
|
+
Pair = node edge node
|
|
13
|
+
|
|
14
|
+
comment = "#" (~lineTerminator any)*
|
|
15
|
+
|
|
16
|
+
space := " " | "\t" | lineTerminator | comment
|
|
17
|
+
|
|
18
|
+
/* lower case is important: it disables whitespace */
|
|
19
|
+
node = nodeWithProps | node_name
|
|
20
|
+
|
|
21
|
+
nodeWithProps = node_name props
|
|
22
|
+
|
|
23
|
+
/* A node name can contain letters, numbers or underscores */
|
|
24
|
+
node_name = (alnum | "_")+
|
|
25
|
+
|
|
26
|
+
props = "(" listOf<prop, ","> ")"
|
|
27
|
+
|
|
28
|
+
prop = alnum+ "=" propValue
|
|
29
|
+
|
|
30
|
+
propValue = alnum+ | quotedProp
|
|
31
|
+
|
|
32
|
+
quotedProp = "\"" (~"\"" any)* "\""
|
|
33
|
+
|
|
34
|
+
edge = "-"
|
|
35
|
+
|
|
36
|
+
lineTerminator = "\n" | "\r"
|
|
37
|
+
}
|
|
38
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfn/project",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Read, serialize, replicate and sync OpenFn projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -28,9 +28,11 @@
|
|
|
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
|
-
"@openfn/lexicon": "^1.2.
|
|
35
|
+
"@openfn/lexicon": "^1.2.4",
|
|
34
36
|
"@openfn/logger": "1.0.6"
|
|
35
37
|
},
|
|
36
38
|
"files": [
|
|
@@ -41,8 +43,7 @@
|
|
|
41
43
|
"test": "pnpm ava",
|
|
42
44
|
"test:watch": "pnpm ava -w",
|
|
43
45
|
"test:types": "pnpm tsc --noEmit --project tsconfig.json",
|
|
44
|
-
"build": "tsup --config ../../tsup.config.js src/index.ts",
|
|
45
|
-
"build:watch": "pnpm build --watch",
|
|
46
|
+
"build": "tsup --config ../../tsup.config.js src/index.ts && cp src/gen/workflow.ohm dist/workflow.ohm",
|
|
46
47
|
"pack": "pnpm pack --pack-destination ../../dist"
|
|
47
48
|
}
|
|
48
49
|
}
|