@openfn/project 0.11.0 → 0.12.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
@@ -8,6 +8,18 @@ A single Project can be Checked Out to disk at a time, meaning its source workfl
8
8
 
9
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
10
 
11
+ ## Structure and Artifects
12
+
13
+ openfn.yaml
14
+
15
+ project file
16
+
17
+ sort of a mix of project.yaml, state.json and config.json
18
+
19
+ This is strictly a representation of a server-side project, it's like the last-sync-state. CLI-only or offline projects do not have one.
20
+
21
+ It's also a portable representation of the project
22
+
11
23
  ### Serializing and Parsing
12
24
 
13
25
  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.
package/dist/index.d.ts CHANGED
@@ -1,8 +1,36 @@
1
1
  import * as l from '@openfn/lexicon';
2
- import l__default, { WorkspaceConfig, UUID } from '@openfn/lexicon';
2
+ import l__default, { SandboxMeta, WorkspaceConfig, UUID } from '@openfn/lexicon';
3
3
  import { Logger } from '@openfn/logger';
4
4
  import { Provisioner } from '@openfn/lexicon/lightning';
5
5
 
6
+ type DiffType = 'added' | 'changed' | 'removed';
7
+ type WorkflowDiff = {
8
+ id: string;
9
+ type: DiffType;
10
+ };
11
+ /**
12
+ * Compare two projects and return a list of workflow changes showing how
13
+ * project B has diverged from project A.
14
+ *
15
+ * Workflows are identified by their ID and compared using version hashes.
16
+ *
17
+ * @param a - The baseline project (e.g., main branch)
18
+ * @param b - The comparison project (e.g., staging branch)
19
+ * @returns Array of workflow diffs indicating how B differs from A:
20
+ * - 'added': workflow exists in B but not in A
21
+ * - 'removed': workflow exists in A but not in B
22
+ * - 'changed': workflow exists in both but has different version hashes
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * const main = await Project.from('fs', { root: '.' });
27
+ * const staging = await Project.from('state', stagingState);
28
+ * const diffs = diff(main, staging);
29
+ * // Shows how staging has diverged from main
30
+ * ```
31
+ */
32
+ declare function diff(a: Project, b: Project): WorkflowDiff[];
33
+
6
34
  type WithMeta<T> = T & {
7
35
  openfn?: l.NodeMeta;
8
36
  };
@@ -60,10 +88,13 @@ type SerializedWorkflow = {
60
88
  openfn?: l.ProjectMeta;
61
89
  };
62
90
 
91
+ declare const SANDBOX_MERGE = "sandbox";
92
+ declare const REPLACE_MERGE = "replace";
63
93
  type MergeProjectOptions = {
64
94
  workflowMappings: Record<string, string>;
65
95
  removeUnmapped: boolean;
66
96
  force: boolean;
97
+ mode: typeof SANDBOX_MERGE | typeof REPLACE_MERGE;
67
98
  };
68
99
 
69
100
  declare class Workspace {
@@ -118,6 +149,7 @@ declare class Project {
118
149
  config: l__default.WorkspaceConfig;
119
150
  collections: any;
120
151
  credentials: string[];
152
+ sandbox?: SandboxMeta;
121
153
  static from(type: 'project', data: any, options: never): Promise<Project>;
122
154
  static from(type: 'state', data: Provisioner.Project, meta?: Partial<l__default.ProjectMeta>, config?: fromAppStateConfig): Promise<Project>;
123
155
  static from(type: 'fs', options: FromFsConfig): Promise<Project>;
@@ -142,6 +174,7 @@ declare class Project {
142
174
  * Returns a map of ids:uuids for everything in the project
143
175
  */
144
176
  getUUIDMap(): UUIDMap;
177
+ diff(project: Project): WorkflowDiff[];
145
178
  canMergeInto(target: Project): boolean;
146
179
  }
147
180
 
@@ -166,4 +199,4 @@ type GenerateProjectOptions = GenerateWorkflowOptions & {
166
199
  declare function generateWorkflow(def: string, options?: Partial<GenerateWorkflowOptions>): Workflow;
167
200
  declare function generateProject(name: string, workflowDefs: string[], options?: Partial<GenerateProjectOptions>): Project;
168
201
 
169
- export { Workspace, Project as default, generateProject, generateWorkflow, jsonToYaml, yamlToJson };
202
+ export { DiffType, WorkflowDiff, Workspace, Project as default, diff, generateProject, generateWorkflow, jsonToYaml, yamlToJson };
package/dist/index.js CHANGED
@@ -305,7 +305,10 @@ function to_app_state_default(project, options = {}) {
305
305
  state.id = uuid;
306
306
  Object.assign(state, rest, project.options);
307
307
  state.project_credentials = project.credentials ?? [];
308
- state.workflows = project.workflows.map(mapWorkflow);
308
+ state.workflows = project.workflows.map(mapWorkflow).reduce((obj, wf) => {
309
+ obj[slugify(wf.name ?? wf.id)] = wf;
310
+ return obj;
311
+ }, {});
309
312
  const shouldReturnYaml = options.format === "yaml" || !options.format && project.config.formats.project === "yaml";
310
313
  if (shouldReturnYaml) {
311
314
  return jsonToYaml(state);
@@ -320,9 +323,9 @@ var mapWorkflow = (workflow) => {
320
323
  const wfState = {
321
324
  ...originalOpenfnProps,
322
325
  id: workflow.openfn?.uuid ?? randomUUID(),
323
- jobs: [],
324
- triggers: [],
325
- edges: [],
326
+ jobs: {},
327
+ triggers: {},
328
+ edges: {},
326
329
  lock_version: workflow.openfn?.lock_version ?? null
327
330
  // TODO needs testing
328
331
  };
@@ -346,7 +349,7 @@ var mapWorkflow = (workflow) => {
346
349
  type: s.type,
347
350
  ...renameKeys(s.openfn, { uuid: "id" })
348
351
  };
349
- wfState.triggers.push(node);
352
+ wfState.triggers[node.type] = node;
350
353
  } else {
351
354
  node = omitBy(pick(s, ["name", "adaptor"]), isNil);
352
355
  const { uuid: uuid2, ...otherOpenFnProps } = s.openfn ?? {};
@@ -358,7 +361,7 @@ var mapWorkflow = (workflow) => {
358
361
  otherOpenFnProps.project_credential_id = s.configuration;
359
362
  }
360
363
  Object.assign(node, defaultJobProps, otherOpenFnProps);
361
- wfState.jobs.push(node);
364
+ wfState.jobs[s.id ?? slugify(s.name)] = node;
362
365
  }
363
366
  Object.keys(s.next ?? {}).forEach((next) => {
364
367
  const rules = s.next[next];
@@ -388,10 +391,15 @@ var mapWorkflow = (workflow) => {
388
391
  e.condition_expression = rules.condition;
389
392
  }
390
393
  }
391
- wfState.edges.push(e);
394
+ wfState.edges[`${s.id}->${next}`] = e;
392
395
  });
393
396
  });
394
- wfState.edges = sortBy(wfState.edges, "id");
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
+ }, {});
395
403
  return wfState;
396
404
  };
397
405
 
@@ -416,16 +424,19 @@ var buildConfig = (config = {}) => ({
416
424
  workflow: config.formats?.workflow ?? "yaml"
417
425
  }
418
426
  });
419
- var extractConfig = (source) => {
427
+ var extractConfig = (source, format) => {
420
428
  const project = {
421
429
  ...source.openfn || {},
422
430
  id: source.id
423
431
  };
432
+ if (source.name) {
433
+ project.name = source.name;
434
+ }
424
435
  const workspace = {
425
436
  ...source.config
426
437
  };
427
438
  const content = { project, workspace };
428
- const format = workspace.formats.openfn;
439
+ format = format ?? workspace.formats.openfn;
429
440
  if (format === "yaml") {
430
441
  return {
431
442
  path: "openfn.yaml",
@@ -621,6 +632,11 @@ var to_project_default = (project, options = {}) => {
621
632
  },
622
633
  isNil4
623
634
  );
635
+ if (project.sandbox?.parentId) {
636
+ proj.sandbox = {
637
+ parentId: project.sandbox.parentId
638
+ };
639
+ }
624
640
  const format = options.format ?? proj.config?.formats.project;
625
641
  if (format === "json") {
626
642
  return proj;
@@ -654,6 +670,7 @@ var from_app_state_default = (state, meta = {}, config = {}) => {
654
670
  collections,
655
671
  inserted_at,
656
672
  updated_at,
673
+ parent_id,
657
674
  ...options
658
675
  } = stateJson;
659
676
  const proj = {
@@ -672,7 +689,12 @@ var from_app_state_default = (state, meta = {}, config = {}) => {
672
689
  inserted_at,
673
690
  updated_at
674
691
  };
675
- proj.workflows = stateJson.workflows.map(mapWorkflow2);
692
+ if (parent_id) {
693
+ proj.sandbox = {
694
+ parentId: parent_id
695
+ };
696
+ }
697
+ proj.workflows = Object.values(stateJson.workflows).map(mapWorkflow2);
676
698
  return new Project(proj, config);
677
699
  };
678
700
  var mapEdge = (edge) => {
@@ -705,20 +727,22 @@ var mapWorkflow2 = (workflow) => {
705
727
  if (workflow.name) {
706
728
  mapped.id = slugify(workflow.name);
707
729
  }
708
- workflow.triggers.forEach((trigger) => {
730
+ Object.values(workflow.triggers).forEach((trigger) => {
709
731
  const { type, ...otherProps } = trigger;
710
732
  if (!mapped.start) {
711
- mapped.start = `trigger-${type}`;
733
+ mapped.start = type;
712
734
  }
713
- const connectedEdges = edges.filter(
735
+ const connectedEdges = Object.values(edges).filter(
714
736
  (e) => e.source_trigger_id === trigger.id
715
737
  );
716
738
  mapped.steps.push({
717
- id: "trigger",
739
+ id: type,
718
740
  type,
719
741
  openfn: renameKeys(otherProps, { id: "uuid" }),
720
742
  next: connectedEdges.reduce((obj, edge) => {
721
- const target = jobs.find((j) => j.id === edge.target_job_id);
743
+ const target = Object.values(jobs).find(
744
+ (j) => j.id === edge.target_job_id
745
+ );
722
746
  if (!target) {
723
747
  throw new Error(`Failed to find ${edge.target_job_id}`);
724
748
  }
@@ -727,8 +751,8 @@ var mapWorkflow2 = (workflow) => {
727
751
  }, {})
728
752
  });
729
753
  });
730
- workflow.jobs.forEach((step) => {
731
- const outboundEdges = edges.filter(
754
+ Object.values(workflow.jobs).forEach((step) => {
755
+ const outboundEdges = Object.values(edges).filter(
732
756
  (e) => e.source_job_id === step.id || e.source_trigger_id === step.id
733
757
  );
734
758
  const {
@@ -751,7 +775,9 @@ var mapWorkflow2 = (workflow) => {
751
775
  }
752
776
  if (outboundEdges.length) {
753
777
  s.next = outboundEdges.reduce((next, edge) => {
754
- const target = jobs.find((j) => j.id === edge.target_job_id);
778
+ const target = Object.values(jobs).find(
779
+ (j) => j.id === edge.target_job_id
780
+ );
755
781
  next[slugify(target.name)] = mapEdge(edge);
756
782
  return next;
757
783
  }, {});
@@ -953,8 +979,16 @@ function mergeWorkflows(source, target, mappings) {
953
979
  return {
954
980
  ...target,
955
981
  ...newSource,
956
- openfn: { ...target.openfn }
957
- // preserving the target uuid. we might need a proper helper function for this.
982
+ openfn: {
983
+ ...target.openfn,
984
+ ...source.openfn,
985
+ // preserving the target uuid. we might need a proper helper function for this
986
+ uuid: target.openfn?.uuid
987
+ },
988
+ options: {
989
+ ...target.options,
990
+ ...source.options
991
+ }
958
992
  };
959
993
  }
960
994
 
@@ -1206,14 +1240,20 @@ function getDuplicates(arr) {
1206
1240
  }
1207
1241
 
1208
1242
  // src/merge/merge-project.ts
1243
+ var SANDBOX_MERGE = "sandbox";
1209
1244
  var UnsafeMergeError = class extends Error {
1210
1245
  };
1246
+ var defaultOptions = {
1247
+ workflowMappings: {},
1248
+ removeUnmapped: false,
1249
+ 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
1255
+ };
1211
1256
  function merge(source, target, opts) {
1212
- const defaultOptions = {
1213
- workflowMappings: {},
1214
- removeUnmapped: false,
1215
- force: true
1216
- };
1217
1257
  const options = defaultsDeep(
1218
1258
  opts,
1219
1259
  defaultOptions
@@ -1274,13 +1314,47 @@ Pass --force to force the merge anyway`
1274
1314
  }
1275
1315
  }
1276
1316
  }
1317
+ const assigns = options.mode === SANDBOX_MERGE ? {
1318
+ workflows: finalWorkflows
1319
+ } : {
1320
+ workflows: finalWorkflows,
1321
+ openfn: {
1322
+ ...target.openfn,
1323
+ ...source.openfn
1324
+ },
1325
+ options: {
1326
+ ...target.options,
1327
+ ...source.options
1328
+ },
1329
+ name: source.name ?? target.name,
1330
+ description: source.description ?? target.description,
1331
+ credentials: source.credentials ?? target.credentials,
1332
+ collections: source.collections ?? target.collections
1333
+ };
1277
1334
  return new Project(
1278
- baseMerge(target, source, ["collections"], {
1279
- workflows: finalWorkflows
1280
- })
1335
+ baseMerge(target, source, ["collections"], assigns)
1281
1336
  );
1282
1337
  }
1283
1338
 
1339
+ // src/util/project-diff.ts
1340
+ function diff(a, b) {
1341
+ const diffs = [];
1342
+ for (const workflowA of a.workflows) {
1343
+ const workflowB = b.getWorkflow(workflowA.id);
1344
+ if (!workflowB) {
1345
+ diffs.push({ id: workflowA.id, type: "removed" });
1346
+ } else if (workflowA.getVersionHash() !== workflowB.getVersionHash()) {
1347
+ diffs.push({ id: workflowA.id, type: "changed" });
1348
+ }
1349
+ }
1350
+ for (const workflowB of b.workflows) {
1351
+ if (!a.getWorkflow(workflowB.id)) {
1352
+ diffs.push({ id: workflowB.id, type: "added" });
1353
+ }
1354
+ }
1355
+ return diffs;
1356
+ }
1357
+
1284
1358
  // src/Project.ts
1285
1359
  var maybeCreateWorkflow = (wf) => wf instanceof Workflow_default ? wf : new Workflow_default(wf);
1286
1360
  var Project = class {
@@ -1308,6 +1382,7 @@ var Project = class {
1308
1382
  config;
1309
1383
  collections;
1310
1384
  credentials;
1385
+ sandbox;
1311
1386
  static async from(type, data, ...rest) {
1312
1387
  switch (type) {
1313
1388
  case "project":
@@ -1332,10 +1407,6 @@ var Project = class {
1332
1407
  static merge(source, target, options) {
1333
1408
  return merge(source, target, options);
1334
1409
  }
1335
- // env is excluded because it's not really part of the project
1336
- // uh maybe
1337
- // maybe this second arg is config - like env, branch rules, serialisation rules
1338
- // stuff that's external to the actual project and managed by the repo
1339
1410
  // TODO maybe the constructor is (data, Workspace)
1340
1411
  constructor(data = {}, meta) {
1341
1412
  this.id = data.id ?? (data.name ? slugify(data.name) : humanId({ separator: "-", capitalize: false }));
@@ -1354,6 +1425,7 @@ var Project = class {
1354
1425
  this.workflows = data.workflows?.map(maybeCreateWorkflow) ?? [];
1355
1426
  this.collections = data.collections;
1356
1427
  this.credentials = data.credentials;
1428
+ this.sandbox = data.sandbox;
1357
1429
  }
1358
1430
  /** Local alias for the project. Comes from the file name. Not shared with Lightning. */
1359
1431
  get alias() {
@@ -1413,6 +1485,10 @@ var Project = class {
1413
1485
  }
1414
1486
  return result;
1415
1487
  }
1488
+ // Compare this project with another and return a list of workflow changes
1489
+ diff(project) {
1490
+ return diff(this, project);
1491
+ }
1416
1492
  canMergeInto(target) {
1417
1493
  const potentialConflicts = {};
1418
1494
  for (const sourceWorkflow of this.workflows) {
@@ -1780,6 +1856,7 @@ var src_default = Project;
1780
1856
  export {
1781
1857
  Workspace,
1782
1858
  src_default as default,
1859
+ diff,
1783
1860
  generateProject,
1784
1861
  generateWorkflow,
1785
1862
  jsonToYaml,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfn/project",
3
- "version": "0.11.0",
3
+ "version": "0.12.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.3.0",
37
+ "@openfn/lexicon": "^1.4.0",
38
38
  "@openfn/logger": "1.1.1"
39
39
  },
40
40
  "files": [