@openfn/project 0.4.0 → 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 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
@@ -97,10 +97,12 @@ declare class Project {
97
97
  declare class Workspace {
98
98
  private config?;
99
99
  private projects;
100
+ private projectPaths;
100
101
  private isValid;
101
102
  constructor(workspacePath: string);
102
103
  list(): Project[];
103
104
  get(id: string): Project | undefined;
105
+ getProjectPath(id: string): string | undefined;
104
106
  getActiveProject(): Project | undefined;
105
107
  getConfig(): OpenfnConfig | undefined;
106
108
  get activeProjectId(): string | undefined;
@@ -114,12 +116,13 @@ type GenerateWorkflowOptions = {
114
116
  name: string;
115
117
  uuidSeed: number;
116
118
  openfnUuid: boolean;
119
+ printErrors: boolean;
117
120
  };
118
121
  /**
119
122
  * Generate a Workflow from a simple text based representation
120
- * The def array contains strings of pairs of nodes
121
- * eg, ['a-b', 'b-c']
123
+ * eg, `a-b b-c a-c`
122
124
  */
123
- declare function generateWorkflow(def: string[], options?: Partial<GenerateWorkflowOptions>): Workflow;
125
+ declare function generateWorkflow(def: string, options?: Partial<GenerateWorkflowOptions>): Workflow;
126
+ declare function generateProject(name: string, workflowDefs: string[], options: Partial<GenerateWorkflowOptions>): Project;
124
127
 
125
- export { Workspace, Project as default, generateWorkflow as generate, jsonToYaml, yamlToJson };
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: path3, content } = extractRepoConfig(project);
266
- files[path3] = content;
270
+ const { path: path4, content } = extractRepoConfig(project);
271
+ files[path4] = content;
267
272
  for (const wf of project.workflows) {
268
- const { path: path4, content: content2 } = extractWorkflow(project, wf.id);
269
- files[path4] = content2;
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: path5, content: content3 } = result;
274
- files[path5] = content3;
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 path3 = nodepath.join(root, workflow.id, workflow.id);
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, path3, format);
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 path3 = nodepath.join(root, `${workflow.id}/${step.id}.js`);
320
+ const path4 = nodepath.join(root, `${workflow.id}/${step.id}.js`);
316
321
  const content = step.expression;
317
- return { path: path3, content };
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 path3 = `${filePath}.${format}`;
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: path3, content };
344
+ return { path: path4, content };
340
345
  };
341
346
 
342
347
  // src/parse/from-app-state.ts
343
- function slugify(text) {
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: slugify(workflow.name),
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[slugify(target.name)] = mapTriggerEdgeCondition(edge);
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: slugify(name2),
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[slugify(target.name)] = mapTriggerEdgeCondition(edge);
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";
@@ -1073,6 +1079,7 @@ var PROJECT_EXTENSIONS = [".yaml", ".yml"];
1073
1079
  var Workspace = class {
1074
1080
  config;
1075
1081
  projects = [];
1082
+ projectPaths = /* @__PURE__ */ new Map();
1076
1083
  isValid = false;
1077
1084
  constructor(workspacePath) {
1078
1085
  const projectsPath = path2.join(workspacePath, PROJECTS_DIRECTORY);
@@ -1087,8 +1094,11 @@ var Workspace = class {
1087
1094
  (fileName) => PROJECT_EXTENSIONS.includes(path2.extname(fileName))
1088
1095
  );
1089
1096
  this.projects = stateFiles.map((file) => {
1090
- const data = fs3.readFileSync(path2.join(projectsPath, file), "utf-8");
1091
- return from_app_state_default(data, { format: "yaml" });
1097
+ const stateFilePath = path2.join(projectsPath, file);
1098
+ const data = fs3.readFileSync(stateFilePath, "utf-8");
1099
+ const project = from_app_state_default(data, { format: "yaml" });
1100
+ this.projectPaths.set(project.name, stateFilePath);
1101
+ return project;
1092
1102
  });
1093
1103
  }
1094
1104
  }
@@ -1098,6 +1108,9 @@ var Workspace = class {
1098
1108
  get(id) {
1099
1109
  return this.projects.find((p) => p.name === id);
1100
1110
  }
1111
+ getProjectPath(id) {
1112
+ return this.projectPaths.get(id);
1113
+ }
1101
1114
  getActiveProject() {
1102
1115
  return this.projects.find((p) => p.name === this.config?.name);
1103
1116
  }
@@ -1112,67 +1125,142 @@ var Workspace = class {
1112
1125
  }
1113
1126
  };
1114
1127
 
1115
- // src/gen/workflow-generator.ts
1128
+ // src/gen/generator.ts
1116
1129
  import { randomUUID as randomUUID2 } from "node:crypto";
1117
-
1118
- // src/util/slugify.ts
1119
- function slugify2(text) {
1120
- return text.replace(/\W/g, " ").trim().replace(/\s+/g, "-").toLowerCase();
1121
- }
1122
-
1123
- // src/gen/workflow-generator.ts
1124
- function gen(def, name = "workflow", uuidSeed, openfnUuid) {
1125
- const ids = /* @__PURE__ */ new Map();
1126
- const nodes = {};
1127
- for (const conn of def) {
1128
- const [from, to] = conn.split("-");
1129
- if (nodes[from]) {
1130
- if (to) {
1131
- if (!nodes[from].next?.[to]) {
1132
- nodes[from].next ??= {};
1133
- 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()
1134
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);
1135
1182
  }
1136
- } else {
1137
- let props;
1138
- if (to) {
1139
- props = { next: { [to]: edge(`${from}-${to}`) } };
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");
1140
1202
  }
1141
- nodes[from] = node(from, props);
1142
- }
1143
- if (to && !nodes[to]) {
1144
- nodes[to] = node(to);
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
+ };
1145
1221
  }
1146
- }
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);
1147
1228
  return {
1148
- name,
1149
- id: slugify2(name),
1150
- steps: Object.values(nodes),
1151
- ...openfnUuid ? { openfn: { uuid: randomUUID2() } } : {}
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
+ }
1152
1244
  };
1153
- function node(id, props = {}) {
1154
- return {
1155
- id,
1156
- ...props,
1157
- openfn: { uuid: uuid(id) }
1158
- };
1245
+ };
1246
+ function generateWorkflow(def, options = {}) {
1247
+ if (!parser) {
1248
+ parser = createParser();
1159
1249
  }
1160
- function edge(id, props = {}) {
1161
- return {
1162
- ...props,
1163
- openfn: { uuid: uuid(id) }
1250
+ const wf = new Workflow_default(parser.parse(def, options));
1251
+ if (options.openfnUuid) {
1252
+ wf.openfn = {
1253
+ uuid: randomUUID2()
1164
1254
  };
1165
1255
  }
1166
- function uuid(id) {
1167
- const muuid = !isNaN(uuidSeed) ? ++uuidSeed : randomUUID2();
1168
- ids.set(id, muuid);
1169
- return muuid;
1170
- }
1256
+ return wf;
1171
1257
  }
1172
- function generateWorkflow(def, options = {}) {
1173
- const { name, uuidSeed, openfnUuid } = options;
1174
- const wf = gen(def, name, uuidSeed, openfnUuid);
1175
- return new Workflow_default(wf);
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
+ });
1176
1264
  }
1177
1265
 
1178
1266
  // src/index.ts
@@ -1180,7 +1268,8 @@ var src_default = Project;
1180
1268
  export {
1181
1269
  Workspace,
1182
1270
  src_default as default,
1183
- generateWorkflow as generate,
1271
+ generateProject,
1272
+ generateWorkflow,
1184
1273
  jsonToYaml,
1185
1274
  yamlToJson
1186
1275
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfn/project",
3
- "version": "0.4.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"