@openfn/project 0.12.0 → 0.13.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 CHANGED
@@ -91,3 +91,5 @@ Reference:
91
91
  parent(propName=propValue,x=y)-child
92
92
  a-b # can comment here to
93
93
  ```
94
+
95
+ Use special names `webhook` and `cron` to create trigger nodes (when converting into app state, the difference between a step and a trigger becomes important).
package/dist/index.d.ts CHANGED
@@ -29,7 +29,19 @@ type WorkflowDiff = {
29
29
  * // Shows how staging has diverged from main
30
30
  * ```
31
31
  */
32
- declare function diff(a: Project, b: Project): WorkflowDiff[];
32
+ declare function diff(a: Project, b: Project, workflows?: string[]): WorkflowDiff[];
33
+
34
+ /**
35
+ *
36
+ * Compare two version hashes
37
+ * Ignores the source specifier (if present)
38
+ */
39
+ declare const match: (a: string, b: string) => boolean;
40
+ type HashOptions = {
41
+ source?: string;
42
+ sha?: boolean;
43
+ };
44
+ declare const generateHash: (workflow: Workflow, { source, sha }?: HashOptions) => string;
33
45
 
34
46
  type WithMeta<T> = T & {
35
47
  openfn?: l.NodeMeta;
@@ -45,6 +57,7 @@ declare class Workflow {
45
57
  get steps(): WithMeta<l.Job & l.Trigger>[];
46
58
  get start(): string | undefined;
47
59
  set start(s: string);
60
+ get history(): string[];
48
61
  _buildIndex(): void;
49
62
  set(id: string, props: Partial<l.Job | l.StepEdge>): this;
50
63
  get(id: string): WithMeta<l.Step | l.Trigger | l.StepEdge>;
@@ -56,7 +69,7 @@ declare class Workflow {
56
69
  getUUID(id: string): string;
57
70
  toJSON(): Object;
58
71
  getUUIDMap(): Record<string, string>;
59
- getVersionHash(): string;
72
+ getVersionHash(options?: HashOptions): string;
60
73
  pushHistory(versionHash: string): void;
61
74
  canMergeInto(target: Workflow): boolean;
62
75
  }
@@ -75,6 +88,7 @@ type FromFsConfig = {
75
88
  root: string;
76
89
  config?: Partial<l.WorkspaceConfig>;
77
90
  logger?: Logger;
91
+ alias?: string;
78
92
  };
79
93
 
80
94
  type SerializedProject = Omit<Partial<l.Project>, 'workflows'> & {
@@ -94,7 +108,16 @@ type MergeProjectOptions = {
94
108
  workflowMappings: Record<string, string>;
95
109
  removeUnmapped: boolean;
96
110
  force: boolean;
111
+ /**
112
+ * If mode is sandbox, basically only content will be merged and all metadata/settings/options/config is ignored
113
+ * If mode is replace, all properties on the source will override the target (including UUIDs, name)
114
+ */
97
115
  mode: typeof SANDBOX_MERGE | typeof REPLACE_MERGE;
116
+ /**
117
+ * If true, only workflows that have changed in the source
118
+ * will be merged.
119
+ */
120
+ onlyUpdated: boolean;
98
121
  };
99
122
 
100
123
  declare class Workspace {
@@ -108,6 +131,8 @@ declare class Workspace {
108
131
  constructor(workspacePath: string, logger?: Logger, validate?: boolean);
109
132
  loadProject(): void;
110
133
  list(): Project[];
134
+ get projectsPath(): string;
135
+ get workflowsPath(): string;
111
136
  /** Get a project by its alias, id or UUID. Can also include a UUID */
112
137
  get(nameyThing: string): Project | null;
113
138
  getProjectPath(id: string): string | undefined;
@@ -130,6 +155,7 @@ type UUIDMap = {
130
155
  type CLIMeta = {
131
156
  version?: number;
132
157
  alias?: string;
158
+ forked_from?: Record<string, string>;
133
159
  };
134
160
  declare class Project {
135
161
  /** Human readable project name. This corresponds to the label in Lightning */
@@ -160,6 +186,7 @@ declare class Project {
160
186
  constructor(data?: Partial<l__default.Project>, meta?: Partial<l__default.WorkspaceConfig> & CLIMeta);
161
187
  /** Local alias for the project. Comes from the file name. Not shared with Lightning. */
162
188
  get alias(): string;
189
+ set alias(value: string);
163
190
  get uuid(): string | undefined;
164
191
  get host(): string | undefined;
165
192
  setConfig(config: Partial<WorkspaceConfig>): void;
@@ -174,8 +201,17 @@ declare class Project {
174
201
  * Returns a map of ids:uuids for everything in the project
175
202
  */
176
203
  getUUIDMap(): UUIDMap;
177
- diff(project: Project): WorkflowDiff[];
204
+ diff(project: Project, workflows?: string[]): WorkflowDiff[];
178
205
  canMergeInto(target: Project): boolean;
206
+ /**
207
+ * Generates the contents of the openfn.yaml file,
208
+ * plus its file path
209
+ */
210
+ generateConfig(): {
211
+ path: string;
212
+ content: string;
213
+ };
214
+ clone(): Project;
179
215
  }
180
216
 
181
217
  declare function yamlToJson(y: string): any;
@@ -187,6 +223,8 @@ type GenerateWorkflowOptions = {
187
223
  printErrors: boolean;
188
224
  uuidMap?: Record<string, string>;
189
225
  openfnUuid: boolean;
226
+ /** If true, will set up a version hash in the history array */
227
+ history: boolean;
190
228
  };
191
229
  type GenerateProjectOptions = GenerateWorkflowOptions & {
192
230
  uuidMap: Array<Record<string, string>>;
@@ -199,4 +237,4 @@ type GenerateProjectOptions = GenerateWorkflowOptions & {
199
237
  declare function generateWorkflow(def: string, options?: Partial<GenerateWorkflowOptions>): Workflow;
200
238
  declare function generateProject(name: string, workflowDefs: string[], options?: Partial<GenerateProjectOptions>): Project;
201
239
 
202
- export { DiffType, WorkflowDiff, Workspace, Project as default, diff, generateProject, generateWorkflow, jsonToYaml, yamlToJson };
240
+ export { DiffType, WorkflowDiff, Workspace, Project as default, diff, generateProject, generateHash as generateVersionHash, generateWorkflow, jsonToYaml, match as versionsEqual, yamlToJson };
package/dist/index.js CHANGED
@@ -14,65 +14,261 @@ function slugify(text) {
14
14
 
15
15
  // src/util/version.ts
16
16
  import crypto from "node:crypto";
17
+ import { get } from "lodash-es";
18
+
19
+ // src/serialize/to-app-state.ts
20
+ import { pick, omitBy, isNil, sortBy } from "lodash-es";
21
+ import { randomUUID } from "node:crypto";
22
+
23
+ // src/util/rename-keys.ts
24
+ function renameKeys(props = {}, keyMap) {
25
+ return Object.fromEntries(
26
+ Object.entries(props).map(([key, value]) => [
27
+ keyMap[key] ? keyMap[key] : key,
28
+ value
29
+ ])
30
+ );
31
+ }
32
+
33
+ // src/util/yaml.ts
34
+ import yaml from "yaml";
35
+ function yamlToJson(y) {
36
+ const doc = yaml.parseDocument(y);
37
+ return doc.toJS();
38
+ }
39
+ function jsonToYaml(json) {
40
+ if (typeof json === "string") {
41
+ json = JSON.parse(json);
42
+ }
43
+ const doc = new yaml.Document(json);
44
+ return yaml.stringify(doc, null, 2);
45
+ }
46
+
47
+ // src/serialize/to-app-state.ts
48
+ var defaultJobProps = {
49
+ // TODO why does the provisioner throw if these keys are not set?
50
+ // Ok, 90% of jobs will have a credenial, but it's still optional right?
51
+ keychain_credential_id: null,
52
+ project_credential_id: null
53
+ };
54
+ function to_app_state_default(project, options = {}) {
55
+ const {
56
+ uuid,
57
+ endpoint,
58
+ env,
59
+ id,
60
+ fetched_at,
61
+ ...rest
62
+ } = project.openfn ?? {};
63
+ const state = omitBy(
64
+ pick(project, ["name", "description", "collections"]),
65
+ isNil
66
+ );
67
+ state.id = uuid;
68
+ Object.assign(state, rest, project.options);
69
+ state.project_credentials = project.credentials ?? [];
70
+ state.workflows = project.workflows.map(mapWorkflow).reduce((obj, wf) => {
71
+ obj[slugify(wf.name ?? wf.id)] = wf;
72
+ return obj;
73
+ }, {});
74
+ const shouldReturnYaml = options.format === "yaml" || !options.format && project.config.formats.project === "yaml";
75
+ if (shouldReturnYaml) {
76
+ return jsonToYaml(state);
77
+ }
78
+ return state;
79
+ }
80
+ var mapWorkflow = (workflow) => {
81
+ if (workflow instanceof Workflow_default) {
82
+ workflow = workflow.toJSON();
83
+ }
84
+ const { uuid, ...originalOpenfnProps } = workflow.openfn ?? {};
85
+ const wfState = {
86
+ ...originalOpenfnProps,
87
+ id: workflow.openfn?.uuid ?? randomUUID(),
88
+ jobs: {},
89
+ triggers: {},
90
+ edges: {},
91
+ lock_version: workflow.openfn?.lock_version ?? null
92
+ // TODO needs testing
93
+ };
94
+ if (workflow.name) {
95
+ wfState.name = workflow.name;
96
+ }
97
+ const lookup = workflow.steps.reduce((obj, next) => {
98
+ if (!next.openfn?.uuid) {
99
+ next.openfn ??= {};
100
+ next.openfn.uuid = randomUUID();
101
+ }
102
+ obj[next.id] = next.openfn.uuid;
103
+ return obj;
104
+ }, {});
105
+ sortBy(workflow.steps, "name").forEach((s) => {
106
+ let isTrigger = false;
107
+ let node;
108
+ if (s.type) {
109
+ isTrigger = true;
110
+ node = {
111
+ type: s.type ?? "webhook",
112
+ // this is mostly for tests
113
+ enabled: s.enabled,
114
+ ...renameKeys(s.openfn, { uuid: "id" })
115
+ };
116
+ wfState.triggers[node.type] = node;
117
+ } else {
118
+ node = omitBy(pick(s, ["name", "adaptor"]), isNil);
119
+ const { uuid: uuid2, ...otherOpenFnProps } = s.openfn ?? {};
120
+ node.id = uuid2;
121
+ if (s.expression) {
122
+ node.body = s.expression;
123
+ }
124
+ if (typeof s.configuration === "string" && !s.configuration.endsWith(".json")) {
125
+ otherOpenFnProps.project_credential_id = s.configuration;
126
+ }
127
+ Object.assign(node, defaultJobProps, otherOpenFnProps);
128
+ wfState.jobs[s.id ?? slugify(s.name)] = node;
129
+ }
130
+ Object.keys(s.next ?? {}).forEach((next) => {
131
+ const rules = s.next[next];
132
+ const { uuid: uuid2, ...otherOpenFnProps } = rules.openfn ?? {};
133
+ const e = {
134
+ id: uuid2 ?? randomUUID(),
135
+ target_job_id: lookup[next],
136
+ enabled: !rules.disabled,
137
+ source_trigger_id: null
138
+ // lightning complains if this isn't set, even if its falsy :(
139
+ };
140
+ Object.assign(e, otherOpenFnProps);
141
+ if (isTrigger) {
142
+ e.source_trigger_id = node.id;
143
+ } else {
144
+ e.source_job_id = node.id;
145
+ }
146
+ if (rules.label) {
147
+ e.condition_label = rules.label;
148
+ }
149
+ if (rules.condition) {
150
+ if (typeof rules.condition === "boolean") {
151
+ e.condition_type = rules.condition ? "always" : "never";
152
+ } else if (rules.condition.match(
153
+ /^(always|never|on_job_success|on_job_failure)$/
154
+ )) {
155
+ e.condition_type = rules.condition;
156
+ } else {
157
+ e.condition_type = "js_expression";
158
+ e.condition_expression = rules.condition;
159
+ }
160
+ }
161
+ wfState.edges[`${s.id}->${next}`] = e;
162
+ });
163
+ });
164
+ wfState.edges = Object.keys(wfState.edges).sort(
165
+ (a, b) => `${wfState.edges[a].id}`.localeCompare("" + wfState.edges[b].id)
166
+ ).reduce((obj, key) => {
167
+ obj[key] = wfState.edges[key];
168
+ return obj;
169
+ }, {});
170
+ return wfState;
171
+ };
172
+
173
+ // src/util/version.ts
17
174
  var SHORT_HASH_LENGTH = 12;
18
175
  function isDefined(v) {
19
176
  return v !== void 0 && v !== null;
20
177
  }
21
- var generateHash = (workflow, source = "cli") => {
178
+ var parse = (version) => {
179
+ if (version.match(":")) {
180
+ const [source, hash] = version.split(":");
181
+ return { source, hash };
182
+ }
183
+ return { hash: version };
184
+ };
185
+ var match = (a, b) => {
186
+ return parse(a).hash === parse(b).hash;
187
+ };
188
+ var generateHash = (workflow, { source = "cli", sha = true } = {}) => {
22
189
  const parts = [];
23
- const wfKeys = ["name", "credentials"].sort();
190
+ const wfState = mapWorkflow(workflow);
191
+ const wfKeys = ["name", "positions"].sort();
24
192
  const stepKeys = [
25
193
  "name",
26
- "adaptors",
27
194
  "adaptor",
28
- // there's both adaptor & adaptors key in steps somehow
29
- "expression",
30
- "configuration",
31
- // assumes a string credential id
32
- "expression"
33
- // TODO need to model trigger types in this, which I think are currently ignored
195
+ "keychain_credential_id",
196
+ "project_credential_id",
197
+ "body"
34
198
  ].sort();
199
+ const triggerKeys = ["type", "cron_expression", "enabled"].sort();
35
200
  const edgeKeys = [
36
- "condition",
201
+ "name",
202
+ // generated
37
203
  "label",
38
- "disabled"
39
- // This feels more like an option - should be excluded?
204
+ "condition_type",
205
+ "condition_label",
206
+ "condition_expression",
207
+ "enabled"
40
208
  ].sort();
41
209
  wfKeys.forEach((key) => {
42
- if (isDefined(workflow[key])) {
43
- parts.push(key, serializeValue(workflow[key]));
210
+ const value = get(workflow, key);
211
+ if (isDefined(value)) {
212
+ parts.push(serializeValue(value));
44
213
  }
45
214
  });
46
- const steps = (workflow.steps || []).slice().sort((a, b) => {
47
- const aName = a.name ?? "";
48
- const bName = b.name ?? "";
49
- return aName.localeCompare(bName);
215
+ for (const triggerId in wfState.triggers) {
216
+ const trigger = wfState.triggers[triggerId];
217
+ triggerKeys.forEach((key) => {
218
+ const value = get(trigger, key);
219
+ if (isDefined(value)) {
220
+ parts.push(serializeValue(value));
221
+ }
222
+ });
223
+ }
224
+ const steps = Object.values(wfState.jobs).sort((a, b) => {
225
+ const aName = a.name ?? a.id ?? "";
226
+ const bName = b.name ?? b.id ?? "";
227
+ return aName.toLowerCase().localeCompare(bName.toLowerCase());
50
228
  });
51
229
  for (const step of steps) {
52
230
  stepKeys.forEach((key) => {
53
- if (isDefined(step[key])) {
54
- parts.push(key, serializeValue(step[key]));
231
+ const value = get(step, key);
232
+ if (isDefined(value)) {
233
+ parts.push(serializeValue(value));
55
234
  }
56
235
  });
57
- if (step.next && Array.isArray(step.next)) {
58
- const steps2 = step.next.slice();
59
- steps2.slice().sort((a, b) => {
60
- const aLabel = a.label || "";
61
- const bLabel = b.label || "";
62
- return aLabel.localeCompare(bLabel);
63
- });
64
- for (const edge of step.next) {
65
- edgeKeys.forEach((key) => {
66
- if (isDefined(edge[key])) {
67
- parts.push(key, serializeValue(edge[key]));
68
- }
69
- });
236
+ }
237
+ const uuidMap = {};
238
+ for (const t in wfState.triggers) {
239
+ const uuid = wfState.triggers[t].id;
240
+ uuidMap[uuid] = wfState.triggers[t];
241
+ wfState.triggers[t].name = wfState.triggers[t].type;
242
+ }
243
+ for (const j in wfState.jobs) {
244
+ const uuid = wfState.jobs[j].id;
245
+ uuidMap[uuid] = wfState.jobs[j];
246
+ }
247
+ const edges = Object.values(wfState.edges).map((edge) => {
248
+ const source2 = uuidMap[edge.source_trigger_id ?? edge.source_job_id];
249
+ const target = uuidMap[edge.target_job_id];
250
+ edge.name = `${source2.name ?? source2.id}-${target.name ?? target.id}`;
251
+ return edge;
252
+ }).sort((a, b) => {
253
+ const aName = a.name ?? "";
254
+ const bName = b.name ?? "";
255
+ return aName.localeCompare(bName);
256
+ });
257
+ for (const edge of edges) {
258
+ edgeKeys.forEach((key) => {
259
+ const value = get(edge, key);
260
+ if (isDefined(value)) {
261
+ parts.push(serializeValue(value));
70
262
  }
71
- }
263
+ });
72
264
  }
73
265
  const str = parts.join("");
74
- const hash = crypto.createHash("sha256").update(str).digest("hex");
75
- return `${source}:${hash.substring(0, SHORT_HASH_LENGTH)}`;
266
+ if (sha) {
267
+ const hash = crypto.createHash("sha256").update(str).digest("hex");
268
+ return `${source}:${hash.substring(0, SHORT_HASH_LENGTH)}`;
269
+ } else {
270
+ return `${source}:${str}`;
271
+ }
76
272
  };
77
273
  function serializeValue(val) {
78
274
  if (typeof val === "object") {
@@ -104,7 +300,7 @@ var Workflow = class {
104
300
  // uuid to ids
105
301
  };
106
302
  this.workflow = clone(workflow);
107
- this.workflow.history = workflow.history?.length ? workflow.history : [];
303
+ this.workflow.history = workflow.history ?? [];
108
304
  const {
109
305
  id,
110
306
  name,
@@ -137,6 +333,9 @@ var Workflow = class {
137
333
  set start(s) {
138
334
  this.workflow.start = s;
139
335
  }
336
+ get history() {
337
+ return this.workflow.history ?? [];
338
+ }
140
339
  _buildIndex() {
141
340
  for (const step of this.workflow.steps) {
142
341
  const s = step;
@@ -168,7 +367,10 @@ var Workflow = class {
168
367
  }
169
368
  // Get properties on any step or edge by id or uuid
170
369
  get(id) {
171
- const item = this.index.edges[id] || this.index.steps[id];
370
+ if (id in this.index.id) {
371
+ id = this.index.id[id];
372
+ }
373
+ let item = this.index.edges[id] || this.index.steps[id];
172
374
  if (!item) {
173
375
  throw new Error(`step/edge with id "${id}" does not exist in workflow`);
174
376
  }
@@ -230,8 +432,8 @@ var Workflow = class {
230
432
  getUUIDMap() {
231
433
  return this.index.uuid;
232
434
  }
233
- getVersionHash() {
234
- return generateHash(this);
435
+ getVersionHash(options) {
436
+ return generateHash(this, options);
235
437
  }
236
438
  pushHistory(versionHash) {
237
439
  this.workflow.history?.push(versionHash);
@@ -254,155 +456,6 @@ __export(serialize_exports, {
254
456
  state: () => to_app_state_default
255
457
  });
256
458
 
257
- // src/serialize/to-app-state.ts
258
- import { pick, omitBy, isNil, sortBy } from "lodash-es";
259
- import { randomUUID } from "node:crypto";
260
-
261
- // src/util/rename-keys.ts
262
- function renameKeys(props = {}, keyMap) {
263
- return Object.fromEntries(
264
- Object.entries(props).map(([key, value]) => [
265
- keyMap[key] ? keyMap[key] : key,
266
- value
267
- ])
268
- );
269
- }
270
-
271
- // src/util/yaml.ts
272
- import yaml from "yaml";
273
- function yamlToJson(y) {
274
- const doc = yaml.parseDocument(y);
275
- return doc.toJS();
276
- }
277
- function jsonToYaml(json) {
278
- if (typeof json === "string") {
279
- json = JSON.parse(json);
280
- }
281
- const doc = new yaml.Document(json);
282
- return yaml.stringify(doc, null, 2);
283
- }
284
-
285
- // src/serialize/to-app-state.ts
286
- var defaultJobProps = {
287
- // TODO why does the provisioner throw if these keys are not set?
288
- // Ok, 90% of jobs will have a credenial, but it's still optional right?
289
- keychain_credential_id: null,
290
- project_credential_id: null
291
- };
292
- function to_app_state_default(project, options = {}) {
293
- const {
294
- uuid,
295
- endpoint,
296
- env,
297
- id,
298
- fetched_at,
299
- ...rest
300
- } = project.openfn ?? {};
301
- const state = omitBy(
302
- pick(project, ["name", "description", "collections"]),
303
- isNil
304
- );
305
- state.id = uuid;
306
- Object.assign(state, rest, project.options);
307
- state.project_credentials = project.credentials ?? [];
308
- state.workflows = project.workflows.map(mapWorkflow).reduce((obj, wf) => {
309
- obj[slugify(wf.name ?? wf.id)] = wf;
310
- return obj;
311
- }, {});
312
- const shouldReturnYaml = options.format === "yaml" || !options.format && project.config.formats.project === "yaml";
313
- if (shouldReturnYaml) {
314
- return jsonToYaml(state);
315
- }
316
- return state;
317
- }
318
- var mapWorkflow = (workflow) => {
319
- if (workflow instanceof Workflow_default) {
320
- workflow = workflow.toJSON();
321
- }
322
- const { uuid, ...originalOpenfnProps } = workflow.openfn ?? {};
323
- const wfState = {
324
- ...originalOpenfnProps,
325
- id: workflow.openfn?.uuid ?? randomUUID(),
326
- jobs: {},
327
- triggers: {},
328
- edges: {},
329
- lock_version: workflow.openfn?.lock_version ?? null
330
- // TODO needs testing
331
- };
332
- if (workflow.name) {
333
- wfState.name = workflow.name;
334
- }
335
- const lookup = workflow.steps.reduce((obj, next) => {
336
- if (!next.openfn?.uuid) {
337
- next.openfn ??= {};
338
- next.openfn.uuid = randomUUID();
339
- }
340
- obj[next.id] = next.openfn.uuid;
341
- return obj;
342
- }, {});
343
- sortBy(workflow.steps, "name").forEach((s) => {
344
- let isTrigger = false;
345
- let node;
346
- if (s.type && !s.expression) {
347
- isTrigger = true;
348
- node = {
349
- type: s.type,
350
- ...renameKeys(s.openfn, { uuid: "id" })
351
- };
352
- wfState.triggers[node.type] = node;
353
- } else {
354
- node = omitBy(pick(s, ["name", "adaptor"]), isNil);
355
- const { uuid: uuid2, ...otherOpenFnProps } = s.openfn ?? {};
356
- node.id = uuid2;
357
- if (s.expression) {
358
- node.body = s.expression;
359
- }
360
- if (typeof s.configuration === "string" && !s.configuration.endsWith(".json")) {
361
- otherOpenFnProps.project_credential_id = s.configuration;
362
- }
363
- Object.assign(node, defaultJobProps, otherOpenFnProps);
364
- wfState.jobs[s.id ?? slugify(s.name)] = node;
365
- }
366
- Object.keys(s.next ?? {}).forEach((next) => {
367
- const rules = s.next[next];
368
- const { uuid: uuid2, ...otherOpenFnProps } = rules.openfn ?? {};
369
- const e = {
370
- id: uuid2 ?? randomUUID(),
371
- target_job_id: lookup[next],
372
- enabled: !rules.disabled,
373
- source_trigger_id: null
374
- // lightning complains if this isn't set, even if its falsy :(
375
- };
376
- Object.assign(e, otherOpenFnProps);
377
- if (isTrigger) {
378
- e.source_trigger_id = node.id;
379
- } else {
380
- e.source_job_id = node.id;
381
- }
382
- if (rules.condition) {
383
- if (typeof rules.condition === "boolean") {
384
- e.condition_type = rules.condition ? "always" : "never";
385
- } else if (rules.condition.match(
386
- /^(always|never|on_job_success|on_job_failure)$/
387
- )) {
388
- e.condition_type = rules.condition;
389
- } else {
390
- e.condition_type = "js_expression";
391
- e.condition_expression = rules.condition;
392
- }
393
- }
394
- wfState.edges[`${s.id}->${next}`] = e;
395
- });
396
- });
397
- wfState.edges = Object.keys(wfState.edges).sort(
398
- (a, b) => `${wfState.edges[a].id}`.localeCompare("" + wfState.edges[b].id)
399
- ).reduce((obj, key) => {
400
- obj[key] = wfState.edges[key];
401
- return obj;
402
- }, {});
403
- return wfState;
404
- };
405
-
406
459
  // src/serialize/to-fs.ts
407
460
  import nodepath from "path";
408
461
  import { omit } from "lodash-es";
@@ -432,6 +485,9 @@ var extractConfig = (source, format) => {
432
485
  if (source.name) {
433
486
  project.name = source.name;
434
487
  }
488
+ if (source.cli.forked_from && Object.keys(source.cli.forked_from).length) {
489
+ project.forked_from = source.cli.forked_from;
490
+ }
435
491
  const workspace = {
436
492
  ...source.config
437
493
  };
@@ -587,10 +643,13 @@ import { omitBy as omitBy3, isNil as isNil4 } from "lodash-es";
587
643
  // src/util/omit-nil.ts
588
644
  import { omitBy as omitBy2, isNil as isNil3 } from "lodash-es";
589
645
  var omitNil = (obj, key) => {
590
- if (obj[key]) {
646
+ if (key && obj[key]) {
591
647
  obj[key] = omitBy2(obj[key], isNil3);
648
+ } else {
649
+ return omitBy2(obj, isNil3);
592
650
  }
593
651
  };
652
+ var omit_nil_default = omitNil;
594
653
  var tidyOpenfn = (obj) => omitNil(obj, "openfn");
595
654
 
596
655
  // src/serialize/to-project.ts
@@ -707,7 +766,7 @@ var mapEdge = (edge) => {
707
766
  e.condition = edge.condition_type;
708
767
  }
709
768
  if (edge.condition_label) {
710
- e.name = edge.condition_label;
769
+ e.label = edge.condition_label;
711
770
  }
712
771
  if (edge.id) {
713
772
  e.openfn = {
@@ -728,7 +787,7 @@ var mapWorkflow2 = (workflow) => {
728
787
  mapped.id = slugify(workflow.name);
729
788
  }
730
789
  Object.values(workflow.triggers).forEach((trigger) => {
731
- const { type, ...otherProps } = trigger;
790
+ const { type, enabled, ...otherProps } = trigger;
732
791
  if (!mapped.start) {
733
792
  mapped.start = type;
734
793
  }
@@ -738,6 +797,7 @@ var mapWorkflow2 = (workflow) => {
738
797
  mapped.steps.push({
739
798
  id: type,
740
799
  type,
800
+ enabled,
741
801
  openfn: renameKeys(otherProps, { id: "uuid" }),
742
802
  next: connectedEdges.reduce((obj, edge) => {
743
803
  const target = Object.values(jobs).find(
@@ -829,16 +889,19 @@ import path3 from "node:path";
829
889
  import { glob } from "glob";
830
890
  import { omit as omit2 } from "lodash-es";
831
891
  var parseProject = async (options) => {
832
- const { root, logger } = options;
892
+ const { root, logger, alias } = options;
833
893
  const { type, content } = findWorkspaceFile(root);
834
894
  const context = loadWorkspaceFile(content, type);
835
895
  const config = buildConfig(options.config ?? context.workspace);
836
896
  const proj = {
837
897
  id: context.project?.id,
838
898
  name: context.project?.name,
839
- openfn: omit2(context.project, ["id"]),
899
+ openfn: omit2(context.project, ["id", "forked_from"]),
840
900
  config,
841
- workflows: []
901
+ workflows: [],
902
+ cli: omit_nil_default({
903
+ forked_from: context.project.forked_from
904
+ })
842
905
  };
843
906
  const workflowDir = config.workflowRoot ?? config.dirs?.workflows ?? "workflows";
844
907
  const fileType = config.formats?.workflow ?? "yaml";
@@ -888,7 +951,10 @@ var parseProject = async (options) => {
888
951
  continue;
889
952
  }
890
953
  }
891
- return new Project(proj, context.workspace);
954
+ return new Project(proj, {
955
+ alias,
956
+ ...context.workspace
957
+ });
892
958
  };
893
959
 
894
960
  // src/util/uuid.ts
@@ -934,7 +1000,7 @@ function baseMerge(target, source, sourceKeys, assigns = {}) {
934
1000
  return assign(target, { ...pickedSource, ...assigns });
935
1001
  }
936
1002
 
937
- // src/merge/merge-node.ts
1003
+ // src/merge/merge-workflow.ts
938
1004
  var clone2 = (obj) => JSON.parse(JSON.stringify(obj));
939
1005
  function mergeWorkflows(source, target, mappings) {
940
1006
  const targetNodes = {};
@@ -979,6 +1045,7 @@ function mergeWorkflows(source, target, mappings) {
979
1045
  return {
980
1046
  ...target,
981
1047
  ...newSource,
1048
+ history: source.history ?? target.history,
982
1049
  openfn: {
983
1050
  ...target.openfn,
984
1051
  ...source.openfn,
@@ -1239,6 +1306,26 @@ function getDuplicates(arr) {
1239
1306
  return Array.from(duplicates);
1240
1307
  }
1241
1308
 
1309
+ // src/util/find-changed-workflows.ts
1310
+ var find_changed_workflows_default = (project) => {
1311
+ const base = project.cli.forked_from ?? project.workflows.reduce((obj, wf) => {
1312
+ if (wf.history.length) {
1313
+ obj[wf.id] = wf.history.at(-1);
1314
+ }
1315
+ return obj;
1316
+ }, {});
1317
+ const changed = [];
1318
+ for (const wf of project.workflows) {
1319
+ if (wf.id in base) {
1320
+ const hash = generateHash(wf);
1321
+ if (hash !== base[wf.id]) {
1322
+ changed.push(wf);
1323
+ }
1324
+ }
1325
+ }
1326
+ return changed;
1327
+ };
1328
+
1242
1329
  // src/merge/merge-project.ts
1243
1330
  var SANDBOX_MERGE = "sandbox";
1244
1331
  var UnsafeMergeError = class extends Error {
@@ -1247,35 +1334,34 @@ var defaultOptions = {
1247
1334
  workflowMappings: {},
1248
1335
  removeUnmapped: false,
1249
1336
  force: true,
1250
- /**
1251
- * If mode is sandbox, basically only content will be merged and all metadata/settings/options/config is ignored
1252
- * If mode is replace, all properties on the source will override the target (including UUIDs, name)
1253
- */
1254
- mode: SANDBOX_MERGE
1337
+ mode: SANDBOX_MERGE,
1338
+ onlyUpdated: false
1255
1339
  };
1256
1340
  function merge(source, target, opts) {
1257
1341
  const options = defaultsDeep(
1258
1342
  opts,
1259
1343
  defaultOptions
1260
1344
  );
1261
- const dupTargetMappings = getDuplicates(
1262
- Object.values(options.workflowMappings ?? {})
1263
- );
1264
- if (dupTargetMappings.length) {
1265
- throw new Error(
1266
- `The following target workflows have multiple source workflows merging into them: ${dupTargetMappings.join(
1267
- ", "
1268
- )}`
1269
- );
1270
- }
1271
1345
  const finalWorkflows = [];
1272
1346
  const usedTargetIds = /* @__PURE__ */ new Set();
1273
- const noMappings = isEmpty(options?.workflowMappings);
1274
- let sourceWorkflows = source.workflows.filter((w) => {
1275
- if (noMappings)
1276
- return true;
1277
- return !!options?.workflowMappings[w.id];
1278
- });
1347
+ let sourceWorkflows = source.workflows;
1348
+ const noMappings = isEmpty(options.workflowMappings);
1349
+ if (options.onlyUpdated) {
1350
+ sourceWorkflows = find_changed_workflows_default(source);
1351
+ }
1352
+ if (!noMappings) {
1353
+ const dupes = getDuplicates(Object.values(options.workflowMappings ?? {}));
1354
+ if (dupes.length) {
1355
+ throw new Error(
1356
+ `The following target workflows have multiple source workflows merging into them: ${dupes.join(
1357
+ ", "
1358
+ )}`
1359
+ );
1360
+ }
1361
+ sourceWorkflows = source.workflows.filter(
1362
+ (w) => !!options.workflowMappings[w.id]
1363
+ );
1364
+ }
1279
1365
  const potentialConflicts = {};
1280
1366
  for (const sourceWorkflow of sourceWorkflows) {
1281
1367
  const targetId = options.workflowMappings?.[sourceWorkflow.id] ?? sourceWorkflow.id;
@@ -1327,6 +1413,7 @@ Pass --force to force the merge anyway`
1327
1413
  ...source.options
1328
1414
  },
1329
1415
  name: source.name ?? target.name,
1416
+ alias: source.alias ?? target.alias,
1330
1417
  description: source.description ?? target.description,
1331
1418
  credentials: source.credentials ?? target.credentials,
1332
1419
  collections: source.collections ?? target.collections
@@ -1337,9 +1424,12 @@ Pass --force to force the merge anyway`
1337
1424
  }
1338
1425
 
1339
1426
  // src/util/project-diff.ts
1340
- function diff(a, b) {
1427
+ function diff(a, b, workflows) {
1341
1428
  const diffs = [];
1342
1429
  for (const workflowA of a.workflows) {
1430
+ if (workflows?.length && !workflows.includes(workflowA.id)) {
1431
+ continue;
1432
+ }
1343
1433
  const workflowB = b.getWorkflow(workflowA.id);
1344
1434
  if (!workflowB) {
1345
1435
  diffs.push({ id: workflowA.id, type: "removed" });
@@ -1348,6 +1438,9 @@ function diff(a, b) {
1348
1438
  }
1349
1439
  }
1350
1440
  for (const workflowB of b.workflows) {
1441
+ if (workflows?.length && !workflows.includes(workflowB.id)) {
1442
+ continue;
1443
+ }
1351
1444
  if (!a.getWorkflow(workflowB.id)) {
1352
1445
  diffs.push({ id: workflowB.id, type: "added" });
1353
1446
  }
@@ -1357,7 +1450,7 @@ function diff(a, b) {
1357
1450
 
1358
1451
  // src/Project.ts
1359
1452
  var maybeCreateWorkflow = (wf) => wf instanceof Workflow_default ? wf : new Workflow_default(wf);
1360
- var Project = class {
1453
+ var Project = class _Project {
1361
1454
  // what schema version is this?
1362
1455
  // And how are we tracking this?
1363
1456
  // version;
@@ -1431,6 +1524,10 @@ var Project = class {
1431
1524
  get alias() {
1432
1525
  return this.cli.alias ?? "main";
1433
1526
  }
1527
+ set alias(value) {
1528
+ this.cli ??= {};
1529
+ this.cli.alias = value;
1530
+ }
1434
1531
  get uuid() {
1435
1532
  return this.openfn?.uuid ? `${this.openfn.uuid}` : void 0;
1436
1533
  }
@@ -1486,8 +1583,8 @@ var Project = class {
1486
1583
  return result;
1487
1584
  }
1488
1585
  // Compare this project with another and return a list of workflow changes
1489
- diff(project) {
1490
- return diff(this, project);
1586
+ diff(project, workflows = []) {
1587
+ return diff(this, project, workflows);
1491
1588
  }
1492
1589
  canMergeInto(target) {
1493
1590
  const potentialConflicts = {};
@@ -1503,6 +1600,16 @@ var Project = class {
1503
1600
  }
1504
1601
  return true;
1505
1602
  }
1603
+ /**
1604
+ * Generates the contents of the openfn.yaml file,
1605
+ * plus its file path
1606
+ */
1607
+ generateConfig() {
1608
+ return extractConfig(this);
1609
+ }
1610
+ clone() {
1611
+ return new _Project(this.serialize("project"));
1612
+ }
1506
1613
  };
1507
1614
  var Project_default = Project;
1508
1615
 
@@ -1623,6 +1730,12 @@ var Workspace = class {
1623
1730
  list() {
1624
1731
  return this.projects;
1625
1732
  }
1733
+ get projectsPath() {
1734
+ return path4.join(this.root, this.config.dirs.projects);
1735
+ }
1736
+ get workflowsPath() {
1737
+ return path4.join(this.root, this.config.dirs.workflows);
1738
+ }
1626
1739
  /** Get a project by its alias, id or UUID. Can also include a UUID */
1627
1740
  get(nameyThing) {
1628
1741
  return match_project_default(nameyThing, this.projects);
@@ -1683,12 +1796,18 @@ var initOperations = (options = {}) => {
1683
1796
  if (!nodes[name]) {
1684
1797
  const id = slugify(name);
1685
1798
  nodes[name] = {
1686
- name,
1687
- id,
1688
- openfn: {
1689
- uuid: uuid(id)
1690
- }
1799
+ id
1691
1800
  };
1801
+ if (/^(cron|webhook)$/.test(name)) {
1802
+ nodes[name].type = name;
1803
+ } else {
1804
+ nodes[name].name = name;
1805
+ }
1806
+ if (options.openfnUuid !== false) {
1807
+ nodes[name].openfn = {
1808
+ uuid: uuid(id)
1809
+ };
1810
+ }
1692
1811
  }
1693
1812
  return nodes[name];
1694
1813
  };
@@ -1725,9 +1844,11 @@ var initOperations = (options = {}) => {
1725
1844
  const n1 = parent.buildWorkflow();
1726
1845
  const n2 = child.buildWorkflow();
1727
1846
  const e = edge.buildWorkflow();
1728
- e.openfn.uuid = uuid(`${n1.id}-${n2.id}`);
1847
+ if (options.openfnUuid !== false) {
1848
+ e.openfn.uuid = uuid(`${n1.id}-${n2.id}`);
1849
+ }
1729
1850
  n1.next ??= {};
1730
- n1.next[n2.name] = e;
1851
+ n1.next[n2.id ?? slugify(n2.name)] = e;
1731
1852
  return [n1, n2];
1732
1853
  },
1733
1854
  // node could just be a node name, or a node with props
@@ -1833,6 +1954,9 @@ function generateWorkflow(def, options = {}) {
1833
1954
  raw.openfn.uuid = uuid;
1834
1955
  }
1835
1956
  const wf = new Workflow_default(raw);
1957
+ if (options.history) {
1958
+ wf.pushHistory(wf.getVersionHash());
1959
+ }
1836
1960
  return wf;
1837
1961
  }
1838
1962
  function generateProject(name, workflowDefs, options = {}) {
@@ -1858,7 +1982,9 @@ export {
1858
1982
  src_default as default,
1859
1983
  diff,
1860
1984
  generateProject,
1985
+ generateHash as generateVersionHash,
1861
1986
  generateWorkflow,
1862
1987
  jsonToYaml,
1988
+ match as versionsEqual,
1863
1989
  yamlToJson
1864
1990
  };
package/dist/workflow.ohm CHANGED
@@ -29,7 +29,7 @@ Workflow {
29
29
 
30
30
  prop = (alnum | "-" | "_")+ "=" propValue
31
31
 
32
- propValue = quoted_prop | bool | int | alnum+
32
+ propValue = quoted_prop | bool | int | alnum+
33
33
 
34
34
  // TODO we only parse numbers as positive ints right now
35
35
  // fine for tests
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfn/project",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Read, serialize, replicate and sync OpenFn projects",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,7 +34,7 @@
34
34
  "lodash-es": "^4.17.21",
35
35
  "ohm-js": "^17.2.1",
36
36
  "yaml": "^2.2.2",
37
- "@openfn/lexicon": "^1.4.0",
37
+ "@openfn/lexicon": "^1.4.1",
38
38
  "@openfn/logger": "1.1.1"
39
39
  },
40
40
  "files": [