@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 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: string;
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: any): Project;
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 | undefined;
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
- * The def array contains strings of pairs of nodes
123
- * eg, ['a-b', 'b-c']
141
+ * eg, `a-b b-c a-c`
124
142
  */
125
- declare function generateWorkflow(def: string[], options?: Partial<GenerateWorkflowOptions>): Workflow;
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 as generate, jsonToYaml, yamlToJson };
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
- this.id = id;
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: path3, content } = extractRepoConfig(project);
266
- files[path3] = content;
282
+ const { path: path4, content } = extractRepoConfig(project);
283
+ files[path4] = content;
267
284
  for (const wf of project.workflows) {
268
- const { path: path4, content: content2 } = extractWorkflow(project, wf.id);
269
- files[path4] = content2;
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: path5, content: content3 } = result;
274
- files[path5] = content3;
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 path3 = nodepath.join(root, workflow.id, workflow.id);
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, path3, format);
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 path3 = nodepath.join(root, `${workflow.id}/${step.id}.js`);
332
+ const path4 = nodepath.join(root, `${workflow.id}/${step.id}.js`);
316
333
  const content = step.expression;
317
- return { path: path3, content };
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 path3 = `${filePath}.${format}`;
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: path3, content };
356
+ return { path: path4, content };
340
357
  };
341
358
 
342
359
  // src/parse/from-app-state.ts
343
- function slugify(text) {
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 (config.format === "yaml") {
348
- state = yamlToJson(state);
349
- } else if (typeof state === "string") {
350
- state = JSON.parse(state);
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, repoConfig);
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[slugify(target.name)] = mapTriggerEdgeCondition(edge);
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: slugify(name2),
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[slugify(target.name)] = mapTriggerEdgeCondition(edge);
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.project ?? "yaml";
500
- const statePath = path.join(root, ".projects", `${identifier}.${format}`);
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
- const workflowDir = config.workflowRoot ?? "workflows";
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(`Workflow "${workflow} not found in project ${project.id}`);
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
- // repo configuration options
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/workflow-generator.ts
1191
+ // src/gen/generator.ts
1123
1192
  import { randomUUID as randomUUID2 } from "node:crypto";
1124
-
1125
- // src/util/slugify.ts
1126
- function slugify2(text) {
1127
- return text.replace(/\W/g, " ").trim().replace(/\s+/g, "-").toLowerCase();
1128
- }
1129
-
1130
- // src/gen/workflow-generator.ts
1131
- function gen(def, name = "workflow", uuidSeed, openfnUuid) {
1132
- const ids = /* @__PURE__ */ new Map();
1133
- const nodes = {};
1134
- for (const conn of def) {
1135
- const [from, to] = conn.split("-");
1136
- if (nodes[from]) {
1137
- if (to) {
1138
- if (!nodes[from].next?.[to]) {
1139
- nodes[from].next ??= {};
1140
- nodes[from].next[to] = edge(`${from}-${to}`);
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
- if (to && !nodes[to]) {
1151
- nodes[to] = node(to);
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
- name,
1156
- id: slugify2(name),
1157
- steps: Object.values(nodes),
1158
- ...openfnUuid ? { openfn: { uuid: randomUUID2() } } : {}
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
- function node(id, props = {}) {
1161
- return {
1162
- id,
1163
- ...props,
1164
- openfn: { uuid: uuid(id) }
1165
- };
1310
+ };
1311
+ function generateWorkflow(def, options = {}) {
1312
+ if (!parser) {
1313
+ parser = createParser();
1166
1314
  }
1167
- function edge(id, props = {}) {
1168
- return {
1169
- ...props,
1170
- openfn: { uuid: uuid(id) }
1171
- };
1315
+ const raw = parser.parse(def, options);
1316
+ if (!raw.name) {
1317
+ raw.name = "Workflow";
1172
1318
  }
1173
- function uuid(id) {
1174
- const muuid = !isNaN(uuidSeed) ? ++uuidSeed : randomUUID2();
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 generateWorkflow(def, options = {}) {
1180
- const { name, uuidSeed, openfnUuid } = options;
1181
- const wf = gen(def, name, uuidSeed, openfnUuid);
1182
- return new Workflow_default(wf);
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
- generateWorkflow as generate,
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.4.1",
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.3",
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
  }