@openfn/project 0.12.1 → 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,9 +69,8 @@ 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
- get history(): string[];
62
74
  canMergeInto(target: Workflow): boolean;
63
75
  }
64
76
 
@@ -76,6 +88,7 @@ type FromFsConfig = {
76
88
  root: string;
77
89
  config?: Partial<l.WorkspaceConfig>;
78
90
  logger?: Logger;
91
+ alias?: string;
79
92
  };
80
93
 
81
94
  type SerializedProject = Omit<Partial<l.Project>, 'workflows'> & {
@@ -95,7 +108,16 @@ type MergeProjectOptions = {
95
108
  workflowMappings: Record<string, string>;
96
109
  removeUnmapped: boolean;
97
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
+ */
98
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;
99
121
  };
100
122
 
101
123
  declare class Workspace {
@@ -133,6 +155,7 @@ type UUIDMap = {
133
155
  type CLIMeta = {
134
156
  version?: number;
135
157
  alias?: string;
158
+ forked_from?: Record<string, string>;
136
159
  };
137
160
  declare class Project {
138
161
  /** Human readable project name. This corresponds to the label in Lightning */
@@ -163,6 +186,7 @@ declare class Project {
163
186
  constructor(data?: Partial<l__default.Project>, meta?: Partial<l__default.WorkspaceConfig> & CLIMeta);
164
187
  /** Local alias for the project. Comes from the file name. Not shared with Lightning. */
165
188
  get alias(): string;
189
+ set alias(value: string);
166
190
  get uuid(): string | undefined;
167
191
  get host(): string | undefined;
168
192
  setConfig(config: Partial<WorkspaceConfig>): void;
@@ -177,8 +201,17 @@ declare class Project {
177
201
  * Returns a map of ids:uuids for everything in the project
178
202
  */
179
203
  getUUIDMap(): UUIDMap;
180
- diff(project: Project): WorkflowDiff[];
204
+ diff(project: Project, workflows?: string[]): WorkflowDiff[];
181
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;
182
215
  }
183
216
 
184
217
  declare function yamlToJson(y: string): any;
@@ -190,6 +223,8 @@ type GenerateWorkflowOptions = {
190
223
  printErrors: boolean;
191
224
  uuidMap?: Record<string, string>;
192
225
  openfnUuid: boolean;
226
+ /** If true, will set up a version hash in the history array */
227
+ history: boolean;
193
228
  };
194
229
  type GenerateProjectOptions = GenerateWorkflowOptions & {
195
230
  uuidMap: Array<Record<string, string>>;
@@ -202,4 +237,4 @@ type GenerateProjectOptions = GenerateWorkflowOptions & {
202
237
  declare function generateWorkflow(def: string, options?: Partial<GenerateWorkflowOptions>): Workflow;
203
238
  declare function generateProject(name: string, workflowDefs: string[], options?: Partial<GenerateProjectOptions>): Project;
204
239
 
205
- 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,15 +432,12 @@ 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);
238
440
  }
239
- get history() {
240
- return this.workflow.history ?? [];
241
- }
242
441
  // return true if the current workflow can be merged into the target workflow without losing any changes
243
442
  canMergeInto(target) {
244
443
  const thisHistory = this.workflow.history?.concat(this.getVersionHash()) ?? [];
@@ -257,155 +456,6 @@ __export(serialize_exports, {
257
456
  state: () => to_app_state_default
258
457
  });
259
458
 
260
- // src/serialize/to-app-state.ts
261
- import { pick, omitBy, isNil, sortBy } from "lodash-es";
262
- import { randomUUID } from "node:crypto";
263
-
264
- // src/util/rename-keys.ts
265
- function renameKeys(props = {}, keyMap) {
266
- return Object.fromEntries(
267
- Object.entries(props).map(([key, value]) => [
268
- keyMap[key] ? keyMap[key] : key,
269
- value
270
- ])
271
- );
272
- }
273
-
274
- // src/util/yaml.ts
275
- import yaml from "yaml";
276
- function yamlToJson(y) {
277
- const doc = yaml.parseDocument(y);
278
- return doc.toJS();
279
- }
280
- function jsonToYaml(json) {
281
- if (typeof json === "string") {
282
- json = JSON.parse(json);
283
- }
284
- const doc = new yaml.Document(json);
285
- return yaml.stringify(doc, null, 2);
286
- }
287
-
288
- // src/serialize/to-app-state.ts
289
- var defaultJobProps = {
290
- // TODO why does the provisioner throw if these keys are not set?
291
- // Ok, 90% of jobs will have a credenial, but it's still optional right?
292
- keychain_credential_id: null,
293
- project_credential_id: null
294
- };
295
- function to_app_state_default(project, options = {}) {
296
- const {
297
- uuid,
298
- endpoint,
299
- env,
300
- id,
301
- fetched_at,
302
- ...rest
303
- } = project.openfn ?? {};
304
- const state = omitBy(
305
- pick(project, ["name", "description", "collections"]),
306
- isNil
307
- );
308
- state.id = uuid;
309
- Object.assign(state, rest, project.options);
310
- state.project_credentials = project.credentials ?? [];
311
- state.workflows = project.workflows.map(mapWorkflow).reduce((obj, wf) => {
312
- obj[slugify(wf.name ?? wf.id)] = wf;
313
- return obj;
314
- }, {});
315
- const shouldReturnYaml = options.format === "yaml" || !options.format && project.config.formats.project === "yaml";
316
- if (shouldReturnYaml) {
317
- return jsonToYaml(state);
318
- }
319
- return state;
320
- }
321
- var mapWorkflow = (workflow) => {
322
- if (workflow instanceof Workflow_default) {
323
- workflow = workflow.toJSON();
324
- }
325
- const { uuid, ...originalOpenfnProps } = workflow.openfn ?? {};
326
- const wfState = {
327
- ...originalOpenfnProps,
328
- id: workflow.openfn?.uuid ?? randomUUID(),
329
- jobs: {},
330
- triggers: {},
331
- edges: {},
332
- lock_version: workflow.openfn?.lock_version ?? null
333
- // TODO needs testing
334
- };
335
- if (workflow.name) {
336
- wfState.name = workflow.name;
337
- }
338
- const lookup = workflow.steps.reduce((obj, next) => {
339
- if (!next.openfn?.uuid) {
340
- next.openfn ??= {};
341
- next.openfn.uuid = randomUUID();
342
- }
343
- obj[next.id] = next.openfn.uuid;
344
- return obj;
345
- }, {});
346
- sortBy(workflow.steps, "name").forEach((s) => {
347
- let isTrigger = false;
348
- let node;
349
- if (s.type && !s.expression) {
350
- isTrigger = true;
351
- node = {
352
- type: s.type,
353
- ...renameKeys(s.openfn, { uuid: "id" })
354
- };
355
- wfState.triggers[node.type] = node;
356
- } else {
357
- node = omitBy(pick(s, ["name", "adaptor"]), isNil);
358
- const { uuid: uuid2, ...otherOpenFnProps } = s.openfn ?? {};
359
- node.id = uuid2;
360
- if (s.expression) {
361
- node.body = s.expression;
362
- }
363
- if (typeof s.configuration === "string" && !s.configuration.endsWith(".json")) {
364
- otherOpenFnProps.project_credential_id = s.configuration;
365
- }
366
- Object.assign(node, defaultJobProps, otherOpenFnProps);
367
- wfState.jobs[s.id ?? slugify(s.name)] = node;
368
- }
369
- Object.keys(s.next ?? {}).forEach((next) => {
370
- const rules = s.next[next];
371
- const { uuid: uuid2, ...otherOpenFnProps } = rules.openfn ?? {};
372
- const e = {
373
- id: uuid2 ?? randomUUID(),
374
- target_job_id: lookup[next],
375
- enabled: !rules.disabled,
376
- source_trigger_id: null
377
- // lightning complains if this isn't set, even if its falsy :(
378
- };
379
- Object.assign(e, otherOpenFnProps);
380
- if (isTrigger) {
381
- e.source_trigger_id = node.id;
382
- } else {
383
- e.source_job_id = node.id;
384
- }
385
- if (rules.condition) {
386
- if (typeof rules.condition === "boolean") {
387
- e.condition_type = rules.condition ? "always" : "never";
388
- } else if (rules.condition.match(
389
- /^(always|never|on_job_success|on_job_failure)$/
390
- )) {
391
- e.condition_type = rules.condition;
392
- } else {
393
- e.condition_type = "js_expression";
394
- e.condition_expression = rules.condition;
395
- }
396
- }
397
- wfState.edges[`${s.id}->${next}`] = e;
398
- });
399
- });
400
- wfState.edges = Object.keys(wfState.edges).sort(
401
- (a, b) => `${wfState.edges[a].id}`.localeCompare("" + wfState.edges[b].id)
402
- ).reduce((obj, key) => {
403
- obj[key] = wfState.edges[key];
404
- return obj;
405
- }, {});
406
- return wfState;
407
- };
408
-
409
459
  // src/serialize/to-fs.ts
410
460
  import nodepath from "path";
411
461
  import { omit } from "lodash-es";
@@ -435,6 +485,9 @@ var extractConfig = (source, format) => {
435
485
  if (source.name) {
436
486
  project.name = source.name;
437
487
  }
488
+ if (source.cli.forked_from && Object.keys(source.cli.forked_from).length) {
489
+ project.forked_from = source.cli.forked_from;
490
+ }
438
491
  const workspace = {
439
492
  ...source.config
440
493
  };
@@ -590,10 +643,13 @@ import { omitBy as omitBy3, isNil as isNil4 } from "lodash-es";
590
643
  // src/util/omit-nil.ts
591
644
  import { omitBy as omitBy2, isNil as isNil3 } from "lodash-es";
592
645
  var omitNil = (obj, key) => {
593
- if (obj[key]) {
646
+ if (key && obj[key]) {
594
647
  obj[key] = omitBy2(obj[key], isNil3);
648
+ } else {
649
+ return omitBy2(obj, isNil3);
595
650
  }
596
651
  };
652
+ var omit_nil_default = omitNil;
597
653
  var tidyOpenfn = (obj) => omitNil(obj, "openfn");
598
654
 
599
655
  // src/serialize/to-project.ts
@@ -710,7 +766,7 @@ var mapEdge = (edge) => {
710
766
  e.condition = edge.condition_type;
711
767
  }
712
768
  if (edge.condition_label) {
713
- e.name = edge.condition_label;
769
+ e.label = edge.condition_label;
714
770
  }
715
771
  if (edge.id) {
716
772
  e.openfn = {
@@ -731,7 +787,7 @@ var mapWorkflow2 = (workflow) => {
731
787
  mapped.id = slugify(workflow.name);
732
788
  }
733
789
  Object.values(workflow.triggers).forEach((trigger) => {
734
- const { type, ...otherProps } = trigger;
790
+ const { type, enabled, ...otherProps } = trigger;
735
791
  if (!mapped.start) {
736
792
  mapped.start = type;
737
793
  }
@@ -741,6 +797,7 @@ var mapWorkflow2 = (workflow) => {
741
797
  mapped.steps.push({
742
798
  id: type,
743
799
  type,
800
+ enabled,
744
801
  openfn: renameKeys(otherProps, { id: "uuid" }),
745
802
  next: connectedEdges.reduce((obj, edge) => {
746
803
  const target = Object.values(jobs).find(
@@ -832,16 +889,19 @@ import path3 from "node:path";
832
889
  import { glob } from "glob";
833
890
  import { omit as omit2 } from "lodash-es";
834
891
  var parseProject = async (options) => {
835
- const { root, logger } = options;
892
+ const { root, logger, alias } = options;
836
893
  const { type, content } = findWorkspaceFile(root);
837
894
  const context = loadWorkspaceFile(content, type);
838
895
  const config = buildConfig(options.config ?? context.workspace);
839
896
  const proj = {
840
897
  id: context.project?.id,
841
898
  name: context.project?.name,
842
- openfn: omit2(context.project, ["id"]),
899
+ openfn: omit2(context.project, ["id", "forked_from"]),
843
900
  config,
844
- workflows: []
901
+ workflows: [],
902
+ cli: omit_nil_default({
903
+ forked_from: context.project.forked_from
904
+ })
845
905
  };
846
906
  const workflowDir = config.workflowRoot ?? config.dirs?.workflows ?? "workflows";
847
907
  const fileType = config.formats?.workflow ?? "yaml";
@@ -891,7 +951,10 @@ var parseProject = async (options) => {
891
951
  continue;
892
952
  }
893
953
  }
894
- return new Project(proj, context.workspace);
954
+ return new Project(proj, {
955
+ alias,
956
+ ...context.workspace
957
+ });
895
958
  };
896
959
 
897
960
  // src/util/uuid.ts
@@ -1243,6 +1306,26 @@ function getDuplicates(arr) {
1243
1306
  return Array.from(duplicates);
1244
1307
  }
1245
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
+
1246
1329
  // src/merge/merge-project.ts
1247
1330
  var SANDBOX_MERGE = "sandbox";
1248
1331
  var UnsafeMergeError = class extends Error {
@@ -1251,35 +1334,34 @@ var defaultOptions = {
1251
1334
  workflowMappings: {},
1252
1335
  removeUnmapped: false,
1253
1336
  force: true,
1254
- /**
1255
- * If mode is sandbox, basically only content will be merged and all metadata/settings/options/config is ignored
1256
- * If mode is replace, all properties on the source will override the target (including UUIDs, name)
1257
- */
1258
- mode: SANDBOX_MERGE
1337
+ mode: SANDBOX_MERGE,
1338
+ onlyUpdated: false
1259
1339
  };
1260
1340
  function merge(source, target, opts) {
1261
1341
  const options = defaultsDeep(
1262
1342
  opts,
1263
1343
  defaultOptions
1264
1344
  );
1265
- const dupTargetMappings = getDuplicates(
1266
- Object.values(options.workflowMappings ?? {})
1267
- );
1268
- if (dupTargetMappings.length) {
1269
- throw new Error(
1270
- `The following target workflows have multiple source workflows merging into them: ${dupTargetMappings.join(
1271
- ", "
1272
- )}`
1273
- );
1274
- }
1275
1345
  const finalWorkflows = [];
1276
1346
  const usedTargetIds = /* @__PURE__ */ new Set();
1277
- const noMappings = isEmpty(options?.workflowMappings);
1278
- let sourceWorkflows = source.workflows.filter((w) => {
1279
- if (noMappings)
1280
- return true;
1281
- return !!options?.workflowMappings[w.id];
1282
- });
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
+ }
1283
1365
  const potentialConflicts = {};
1284
1366
  for (const sourceWorkflow of sourceWorkflows) {
1285
1367
  const targetId = options.workflowMappings?.[sourceWorkflow.id] ?? sourceWorkflow.id;
@@ -1331,6 +1413,7 @@ Pass --force to force the merge anyway`
1331
1413
  ...source.options
1332
1414
  },
1333
1415
  name: source.name ?? target.name,
1416
+ alias: source.alias ?? target.alias,
1334
1417
  description: source.description ?? target.description,
1335
1418
  credentials: source.credentials ?? target.credentials,
1336
1419
  collections: source.collections ?? target.collections
@@ -1341,9 +1424,12 @@ Pass --force to force the merge anyway`
1341
1424
  }
1342
1425
 
1343
1426
  // src/util/project-diff.ts
1344
- function diff(a, b) {
1427
+ function diff(a, b, workflows) {
1345
1428
  const diffs = [];
1346
1429
  for (const workflowA of a.workflows) {
1430
+ if (workflows?.length && !workflows.includes(workflowA.id)) {
1431
+ continue;
1432
+ }
1347
1433
  const workflowB = b.getWorkflow(workflowA.id);
1348
1434
  if (!workflowB) {
1349
1435
  diffs.push({ id: workflowA.id, type: "removed" });
@@ -1352,6 +1438,9 @@ function diff(a, b) {
1352
1438
  }
1353
1439
  }
1354
1440
  for (const workflowB of b.workflows) {
1441
+ if (workflows?.length && !workflows.includes(workflowB.id)) {
1442
+ continue;
1443
+ }
1355
1444
  if (!a.getWorkflow(workflowB.id)) {
1356
1445
  diffs.push({ id: workflowB.id, type: "added" });
1357
1446
  }
@@ -1361,7 +1450,7 @@ function diff(a, b) {
1361
1450
 
1362
1451
  // src/Project.ts
1363
1452
  var maybeCreateWorkflow = (wf) => wf instanceof Workflow_default ? wf : new Workflow_default(wf);
1364
- var Project = class {
1453
+ var Project = class _Project {
1365
1454
  // what schema version is this?
1366
1455
  // And how are we tracking this?
1367
1456
  // version;
@@ -1435,6 +1524,10 @@ var Project = class {
1435
1524
  get alias() {
1436
1525
  return this.cli.alias ?? "main";
1437
1526
  }
1527
+ set alias(value) {
1528
+ this.cli ??= {};
1529
+ this.cli.alias = value;
1530
+ }
1438
1531
  get uuid() {
1439
1532
  return this.openfn?.uuid ? `${this.openfn.uuid}` : void 0;
1440
1533
  }
@@ -1490,8 +1583,8 @@ var Project = class {
1490
1583
  return result;
1491
1584
  }
1492
1585
  // Compare this project with another and return a list of workflow changes
1493
- diff(project) {
1494
- return diff(this, project);
1586
+ diff(project, workflows = []) {
1587
+ return diff(this, project, workflows);
1495
1588
  }
1496
1589
  canMergeInto(target) {
1497
1590
  const potentialConflicts = {};
@@ -1507,6 +1600,16 @@ var Project = class {
1507
1600
  }
1508
1601
  return true;
1509
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
+ }
1510
1613
  };
1511
1614
  var Project_default = Project;
1512
1615
 
@@ -1693,12 +1796,18 @@ var initOperations = (options = {}) => {
1693
1796
  if (!nodes[name]) {
1694
1797
  const id = slugify(name);
1695
1798
  nodes[name] = {
1696
- name,
1697
- id,
1698
- openfn: {
1699
- uuid: uuid(id)
1700
- }
1799
+ id
1701
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
+ }
1702
1811
  }
1703
1812
  return nodes[name];
1704
1813
  };
@@ -1735,9 +1844,11 @@ var initOperations = (options = {}) => {
1735
1844
  const n1 = parent.buildWorkflow();
1736
1845
  const n2 = child.buildWorkflow();
1737
1846
  const e = edge.buildWorkflow();
1738
- e.openfn.uuid = uuid(`${n1.id}-${n2.id}`);
1847
+ if (options.openfnUuid !== false) {
1848
+ e.openfn.uuid = uuid(`${n1.id}-${n2.id}`);
1849
+ }
1739
1850
  n1.next ??= {};
1740
- n1.next[n2.name] = e;
1851
+ n1.next[n2.id ?? slugify(n2.name)] = e;
1741
1852
  return [n1, n2];
1742
1853
  },
1743
1854
  // node could just be a node name, or a node with props
@@ -1843,6 +1954,9 @@ function generateWorkflow(def, options = {}) {
1843
1954
  raw.openfn.uuid = uuid;
1844
1955
  }
1845
1956
  const wf = new Workflow_default(raw);
1957
+ if (options.history) {
1958
+ wf.pushHistory(wf.getVersionHash());
1959
+ }
1846
1960
  return wf;
1847
1961
  }
1848
1962
  function generateProject(name, workflowDefs, options = {}) {
@@ -1868,7 +1982,9 @@ export {
1868
1982
  src_default as default,
1869
1983
  diff,
1870
1984
  generateProject,
1985
+ generateHash as generateVersionHash,
1871
1986
  generateWorkflow,
1872
1987
  jsonToYaml,
1988
+ match as versionsEqual,
1873
1989
  yamlToJson
1874
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.1",
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": [