@openfn/project 0.9.0 → 0.9.2

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/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as l from '@openfn/lexicon';
2
2
  import l__default, { WorkspaceConfig, UUID } from '@openfn/lexicon';
3
+ import { Logger } from '@openfn/logger';
3
4
  import { Provisioner } from '@openfn/lexicon/lightning';
4
5
 
5
6
  type WithMeta<T> = T & {
@@ -42,6 +43,17 @@ type FromFsConfig = {
42
43
  root: string;
43
44
  };
44
45
 
46
+ type SerializedProject = Omit<Partial<l.Project>, 'workflows'> & {
47
+ version: number;
48
+ workflows: SerializedWorkflow[];
49
+ };
50
+ type SerializedWorkflow = {
51
+ id: string;
52
+ name: string;
53
+ steps: WithMeta<l.Step[]>;
54
+ openfn?: l.ProjectMeta;
55
+ };
56
+
45
57
  type MergeProjectOptions = {
46
58
  workflowMappings: Record<string, string>;
47
59
  removeUnmapped: boolean;
@@ -54,7 +66,8 @@ declare class Workspace {
54
66
  private projects;
55
67
  private projectPaths;
56
68
  private isValid;
57
- constructor(workspacePath: string);
69
+ private logger;
70
+ constructor(workspacePath: string, logger?: Logger);
58
71
  loadProject(): void;
59
72
  list(): Project[];
60
73
  /** Get a project by its id or UUID */
@@ -89,6 +102,7 @@ declare class Project {
89
102
  config: l__default.WorkspaceConfig;
90
103
  collections: any;
91
104
  credentials: string[];
105
+ static from(type: 'project', data: any, options: never): Promise<Project>;
92
106
  static from(type: 'state', data: Provisioner.Project, meta?: Partial<l__default.ProjectMeta>, config?: fromAppStateConfig): Promise<Project>;
93
107
  static from(type: 'fs', options: FromFsConfig): Promise<Project>;
94
108
  static from(type: 'path', data: string, options?: {
@@ -97,7 +111,9 @@ declare class Project {
97
111
  static merge(source: Project, target: Project, options?: Partial<MergeProjectOptions>): Project;
98
112
  constructor(data: Partial<l__default.Project>, config?: Partial<l__default.WorkspaceConfig>);
99
113
  setConfig(config: Partial<WorkspaceConfig>): void;
100
- serialize(type?: 'json' | 'yaml' | 'fs' | 'state', options?: any): any;
114
+ serialize(type: 'project', options?: any): SerializedProject | string;
115
+ serialize(type: 'state', options?: any): Provisioner.Project | string;
116
+ serialize(type: 'fs', options?: any): Record<string, string>;
101
117
  getWorkflow(idOrName: string): Workflow | undefined;
102
118
  getIdentifier(): string;
103
119
  getUUID(workflow: string | Workflow, stepId: string, otherStep?: string): any;
@@ -105,6 +121,7 @@ declare class Project {
105
121
  * Returns a map of ids:uuids for everything in the project
106
122
  */
107
123
  getUUIDMap(): UUIDMap;
124
+ canMergeInto(target: Project): boolean;
108
125
  }
109
126
 
110
127
  declare function yamlToJson(y: string): any;
package/dist/index.js CHANGED
@@ -105,7 +105,7 @@ var Workflow = class {
105
105
  };
106
106
  this.workflow = clone(workflow);
107
107
  this.workflow.history = workflow.history?.length ? workflow.history : [];
108
- const { id, name, openfn, steps, ...options } = workflow;
108
+ const { id, name, openfn, steps, history, ...options } = workflow;
109
109
  if (!(id || name)) {
110
110
  throw new Error("A Workflow MUST have a name or id");
111
111
  }
@@ -151,7 +151,7 @@ var Workflow = class {
151
151
  Object.assign(item, props);
152
152
  return this;
153
153
  }
154
- // Get properties on any step or edge by id
154
+ // Get properties on any step or edge by id or uuid
155
155
  get(id) {
156
156
  const item = this.index.edges[id] || this.index.steps[id];
157
157
  if (!item) {
@@ -210,7 +210,7 @@ var Workflow = class {
210
210
  return this.index.uuid[id];
211
211
  }
212
212
  toJSON() {
213
- return this.workflow;
213
+ return clone(this.workflow);
214
214
  }
215
215
  getUUIDMap() {
216
216
  return this.index.uuid;
@@ -235,28 +235,10 @@ var Workflow_default = Workflow;
235
235
  var serialize_exports = {};
236
236
  __export(serialize_exports, {
237
237
  fs: () => to_fs_default,
238
- json: () => to_json_default,
238
+ project: () => to_project_default,
239
239
  state: () => to_app_state_default
240
240
  });
241
241
 
242
- // src/serialize/to-json.ts
243
- function to_json_default(project) {
244
- return {
245
- // There must be a better way to do this?
246
- // Do we just serialize all public fields?
247
- id: project.id,
248
- name: project.name,
249
- description: project.description,
250
- config: project.config,
251
- meta: project.meta,
252
- workflows: project.workflows.map((w) => w.toJSON()),
253
- collections: project.collections,
254
- credentials: project.credentials,
255
- openfn: project.openfn,
256
- options: project.options
257
- };
258
- }
259
-
260
242
  // src/serialize/to-app-state.ts
261
243
  import { pick, omitBy, isNil, sortBy } from "lodash-es";
262
244
  import { randomUUID } from "node:crypto";
@@ -291,7 +273,14 @@ var defaultJobProps = {
291
273
  project_credential_id: null
292
274
  };
293
275
  function to_app_state_default(project, options = {}) {
294
- const { uuid, endpoint, env, ...rest } = project.openfn ?? {};
276
+ const {
277
+ uuid,
278
+ endpoint,
279
+ env,
280
+ id,
281
+ fetched_at,
282
+ ...rest
283
+ } = project.openfn ?? {};
295
284
  const state = omitBy(
296
285
  pick(project, ["name", "description", "collections"]),
297
286
  isNil
@@ -382,6 +371,7 @@ var mapWorkflow = (workflow) => {
382
371
 
383
372
  // src/serialize/to-fs.ts
384
373
  import nodepath from "path";
374
+ import { omit } from "lodash-es";
385
375
 
386
376
  // src/util/config.ts
387
377
  import { readFileSync } from "node:fs";
@@ -508,10 +498,16 @@ var extractWorkflow = (project, workflowId) => {
508
498
  // Not crazy about this - maybe we should do something better? Or do we like the consistency?
509
499
  options: workflow.options,
510
500
  steps: workflow.steps.map((step) => {
511
- const { openfn, expression, ...mapped } = step;
501
+ const { openfn, expression, next, ...mapped } = step;
512
502
  if (expression) {
513
503
  mapped.expression = `./${step.id}.js`;
514
504
  }
505
+ if (next && typeof next === "object") {
506
+ mapped.next = {};
507
+ for (const id in next) {
508
+ mapped.next[id] = omit(next[id], ["openfn"]);
509
+ }
510
+ }
515
511
  return mapped;
516
512
  })
517
513
  };
@@ -546,18 +542,71 @@ var handleOutput = (data, filePath, format) => {
546
542
  return { path: path5, content };
547
543
  };
548
544
 
549
- // src/parse/from-app-state.ts
550
- var from_app_state_default = (state, meta = {}, config = {}) => {
551
- let stateJson;
552
- if (typeof state === "string") {
553
- if (config.format === "yaml") {
554
- stateJson = yamlToJson(state);
545
+ // src/serialize/to-project.ts
546
+ import { omitBy as omitBy2, isNil as isNil3 } from "lodash-es";
547
+ var SERIALIZE_VERSION = 2;
548
+ var to_project_default = (project, options = {}) => {
549
+ const proj = omitBy2(
550
+ {
551
+ id: project.id,
552
+ name: project.name,
553
+ version: SERIALIZE_VERSION,
554
+ // important!
555
+ description: project.description,
556
+ collections: project.collections,
557
+ credentials: project.credentials,
558
+ openfn: omitBy2(project.openfn, isNil3),
559
+ meta: project.meta,
560
+ options: omitBy2(project.options, isNil3),
561
+ workflows: project.workflows.map((w) => {
562
+ const obj = w.toJSON();
563
+ if (obj.openfn) {
564
+ obj.openfn = omitBy2(obj.openfn, isNil3);
565
+ }
566
+ if (obj.steps) {
567
+ obj.steps = obj.steps.sort((a, b) => {
568
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
569
+ });
570
+ obj.steps.forEach((s) => {
571
+ s.openfn = omitBy2(s.openfn, isNil3);
572
+ if (s.next && typeof s.next !== "string") {
573
+ for (const id in s.next) {
574
+ const edge = s.next[id];
575
+ if (edge.openfn) {
576
+ edge.openfn = omitBy2(edge.openfn, isNil3);
577
+ }
578
+ }
579
+ }
580
+ });
581
+ }
582
+ return obj;
583
+ })
584
+ },
585
+ isNil3
586
+ );
587
+ const format = options.format ?? proj.config?.formats.project;
588
+ if (format === "json") {
589
+ return proj;
590
+ }
591
+ return jsonToYaml(proj);
592
+ };
593
+
594
+ // src/util/ensure-json.ts
595
+ var ensure_json_default = (obj) => {
596
+ if (typeof obj === "string") {
597
+ const firstChar = obj.trim()[0];
598
+ if (firstChar === "{" || firstChar === "[") {
599
+ return JSON.parse(obj);
555
600
  } else {
556
- stateJson = JSON.parse(state);
601
+ return yamlToJson(obj);
557
602
  }
558
- } else {
559
- stateJson = state;
560
603
  }
604
+ return obj;
605
+ };
606
+
607
+ // src/parse/from-app-state.ts
608
+ var from_app_state_default = (state, meta = {}, config = {}) => {
609
+ let stateJson = ensure_json_default(state);
561
610
  delete config.format;
562
611
  const {
563
612
  id,
@@ -606,10 +655,11 @@ var mapTriggerEdgeCondition = (edge) => {
606
655
  return e;
607
656
  };
608
657
  var mapWorkflow2 = (workflow) => {
609
- const { jobs, edges, triggers, name, ...remoteProps } = workflow;
658
+ const { jobs, edges, triggers, name, version_history, ...remoteProps } = workflow;
610
659
  const mapped = {
611
660
  name: workflow.name,
612
661
  steps: [],
662
+ history: workflow.version_history ?? [],
613
663
  openfn: renameKeys(remoteProps, { id: "uuid" })
614
664
  };
615
665
  if (workflow.name) {
@@ -660,24 +710,32 @@ var mapWorkflow2 = (workflow) => {
660
710
  };
661
711
 
662
712
  // src/parse/from-path.ts
663
- import { extname } from "node:path";
664
713
  import { readFile } from "node:fs/promises";
665
- import { omit } from "lodash-es";
666
- var from_path_default = async (path5, config = {}) => {
667
- const ext = extname(path5).toLowerCase();
668
- const source = await readFile(path5, "utf8");
669
- let state;
670
- if (ext === ".json") {
671
- config.format = "json";
672
- state = JSON.parse(source);
673
- } else if (ext.match(/(ya?ml)$/)) {
674
- config.format = "yaml";
675
- state = yamlToJson(source);
714
+
715
+ // src/parse/from-project.ts
716
+ var from_project_default = (data, config) => {
717
+ let rawJson = ensure_json_default(data);
718
+ let json;
719
+ if (rawJson.version) {
720
+ json = from_v2(rawJson);
676
721
  } else {
677
- throw new Error(`Cannot load a project from a ${ext} file`);
722
+ json = from_v1(rawJson);
678
723
  }
679
- const meta = {};
680
- return from_app_state_default(state, meta, omit(config, ["format"]));
724
+ return new Project_default(json, config);
725
+ };
726
+ var from_v1 = (data) => {
727
+ return from_app_state_default(data);
728
+ };
729
+ var from_v2 = (data) => {
730
+ return {
731
+ ...data
732
+ };
733
+ };
734
+
735
+ // src/parse/from-path.ts
736
+ var from_path_default = async (path5, config = {}) => {
737
+ const source = await readFile(path5, "utf8");
738
+ return from_project_default(source, config);
681
739
  };
682
740
 
683
741
  // src/parse/from-fs.ts
@@ -699,6 +757,7 @@ var get_identifier_default = (config = {}) => {
699
757
  };
700
758
 
701
759
  // src/parse/from-fs.ts
760
+ import { omit as omit2 } from "lodash-es";
702
761
  var parseProject = async (options) => {
703
762
  const { root } = options;
704
763
  const { type, content } = findWorkspaceFile(root);
@@ -717,13 +776,13 @@ var parseProject = async (options) => {
717
776
  `${identifier}.${format}`
718
777
  );
719
778
  const stateFile = await fs.readFile(statePath, "utf8");
720
- state = from_app_state_default(stateFile, { format });
779
+ state = from_project_default(stateFile, config);
721
780
  } catch (e) {
722
781
  console.warn(`Failed to find state file for ${identifier}`);
723
782
  }
724
783
  const proj = {
725
784
  name: state?.name,
726
- openfn: context.project,
785
+ openfn: omit2(context.project, ["id"]),
727
786
  config,
728
787
  workflows: []
729
788
  };
@@ -738,12 +797,12 @@ var parseProject = async (options) => {
738
797
  try {
739
798
  const wf = fileType === "yaml" ? yamlToJson(candidate) : JSON.parse(candidate);
740
799
  if (wf.id && Array.isArray(wf.steps)) {
741
- const wfState = (state && state.getWorkflow(wf.id)) ?? {};
742
- wf.openfn = {
743
- uuid: wfState.openfn?.uuid ?? null
744
- // TODO do we need to transfer more stuff? Options maybe?
745
- };
800
+ const wfState = state?.getWorkflow(wf.id);
801
+ wf.openfn = Object.assign({}, wfState?.openfn, {
802
+ uuid: wfState?.openfn?.uuid ?? null
803
+ });
746
804
  for (const step of wf.steps) {
805
+ const stateStep = wfState?.get(step.id);
747
806
  if (step.expression && step.expression.endsWith(".js")) {
748
807
  const dir = path2.dirname(filePath);
749
808
  const exprPath = path2.join(dir, step.expression);
@@ -754,15 +813,14 @@ var parseProject = async (options) => {
754
813
  console.error(`Error loading expression from ${exprPath}`);
755
814
  }
756
815
  }
757
- const uuid = state?.getUUID(wf.id, step.id) ?? null;
758
- step.openfn = { uuid };
816
+ step.openfn = Object.assign({}, stateStep?.openfn);
759
817
  for (const target in step.next || {}) {
760
818
  if (typeof step.next[target] === "boolean") {
761
819
  const bool = step.next[target];
762
820
  step.next[target] = { condition: bool };
763
821
  }
764
- const uuid2 = state?.getUUID(wf.id, step.id, target) ?? null;
765
- step.next[target].openfn = { uuid: uuid2 };
822
+ const uuid = state?.getUUID(wf.id, step.id, target) ?? null;
823
+ step.next[target].openfn = { uuid };
766
824
  }
767
825
  }
768
826
  proj.workflows.push(wf);
@@ -1210,6 +1268,7 @@ var Project = class {
1210
1268
  options;
1211
1269
  // local metadata used by the CLI
1212
1270
  // This stuff is not synced back to lightning
1271
+ // TODO maybe rename cli or local
1213
1272
  meta;
1214
1273
  // this contains meta about the connected openfn project
1215
1274
  openfn;
@@ -1218,17 +1277,23 @@ var Project = class {
1218
1277
  collections;
1219
1278
  credentials;
1220
1279
  static async from(type, data, ...rest) {
1221
- if (type === "state") {
1222
- return from_app_state_default(data, rest[0], rest[1]);
1223
- } else if (type === "fs") {
1224
- return parseProject(data);
1225
- } else if (type === "path") {
1226
- return from_path_default(data, rest[0]);
1280
+ switch (type) {
1281
+ case "project":
1282
+ var [config] = rest;
1283
+ return from_project_default(data, config);
1284
+ case "state":
1285
+ return from_app_state_default(data, rest[0], rest[1]);
1286
+ case "fs":
1287
+ return parseProject(data);
1288
+ case "path":
1289
+ var [config] = rest;
1290
+ return from_path_default(data, config);
1291
+ default:
1292
+ throw new Error(`Didn't recognize type ${type}`);
1227
1293
  }
1228
- throw new Error(`Didn't recognize type ${type}`);
1229
1294
  }
1230
1295
  // Diff two projects
1231
- // /static diff(a: Project, b: Project) {}
1296
+ // static diff(a: Project, b: Project) {}
1232
1297
  // Merge a source project (staging) into the target project (main)
1233
1298
  // Returns a new Project
1234
1299
  // TODO: throw if histories have diverged
@@ -1254,7 +1319,7 @@ var Project = class {
1254
1319
  setConfig(config) {
1255
1320
  this.config = buildConfig(config);
1256
1321
  }
1257
- serialize(type = "json", options) {
1322
+ serialize(type = "project", options) {
1258
1323
  if (type in serialize_exports) {
1259
1324
  return serialize_exports[type](this, options);
1260
1325
  }
@@ -1293,10 +1358,25 @@ var Project = class {
1293
1358
  }
1294
1359
  return result;
1295
1360
  }
1361
+ canMergeInto(target) {
1362
+ const potentialConflicts = {};
1363
+ for (const sourceWorkflow of this.workflows) {
1364
+ const targetId = sourceWorkflow.id;
1365
+ const targetWorkflow = target.getWorkflow(targetId);
1366
+ if (targetWorkflow && !sourceWorkflow.canMergeInto(targetWorkflow)) {
1367
+ potentialConflicts[sourceWorkflow.id] = targetWorkflow?.id;
1368
+ }
1369
+ }
1370
+ if (Object.keys(potentialConflicts).length) {
1371
+ return false;
1372
+ }
1373
+ return true;
1374
+ }
1296
1375
  };
1297
1376
  var Project_default = Project;
1298
1377
 
1299
1378
  // src/Workspace.ts
1379
+ import createLogger from "@openfn/logger";
1300
1380
  import path3 from "node:path";
1301
1381
  import fs3 from "node:fs";
1302
1382
 
@@ -1317,26 +1397,29 @@ function pathExists(fpath, type) {
1317
1397
 
1318
1398
  // src/Workspace.ts
1319
1399
  var Workspace = class {
1320
- // @ts-ignore config not defininitely assigned - it sure is
1400
+ // @ts-ignore config not definitely assigned - it sure is
1321
1401
  config;
1322
1402
  activeProject;
1323
1403
  projects = [];
1324
1404
  projectPaths = /* @__PURE__ */ new Map();
1325
1405
  isValid = false;
1326
- constructor(workspacePath) {
1327
- let context;
1406
+ logger;
1407
+ constructor(workspacePath, logger) {
1408
+ this.logger = logger ?? createLogger("Workspace", { level: "info" });
1409
+ let context = { workspace: void 0, project: void 0 };
1328
1410
  try {
1329
1411
  const { type, content } = findWorkspaceFile(workspacePath);
1330
1412
  context = loadWorkspaceFile(content, type);
1331
1413
  this.isValid = true;
1332
1414
  } catch (e) {
1333
- console.error(e);
1334
- return;
1415
+ this.logger.warn(
1416
+ `Could not find openfn.yaml at ${workspacePath}. Using default values.`
1417
+ );
1335
1418
  }
1336
1419
  this.config = buildConfig(context.workspace);
1337
1420
  this.activeProject = context.project;
1338
1421
  const projectsPath = path3.join(workspacePath, this.config.dirs.projects);
1339
- if (this.isValid && pathExists(projectsPath, "directory")) {
1422
+ if (pathExists(projectsPath, "directory")) {
1340
1423
  const ext = `.${this.config.formats.project}`;
1341
1424
  const stateFiles = fs3.readdirSync(projectsPath).filter(
1342
1425
  (fileName) => path3.extname(fileName) === ext && path3.parse(fileName).name !== "openfn"
@@ -1345,14 +1428,9 @@ var Workspace = class {
1345
1428
  const stateFilePath = path3.join(projectsPath, file);
1346
1429
  try {
1347
1430
  const data = fs3.readFileSync(stateFilePath, "utf-8");
1348
- const project = from_app_state_default(
1349
- data,
1350
- {},
1351
- {
1352
- ...this.config,
1353
- format: this.config?.formats.project
1354
- }
1355
- );
1431
+ const project = from_project_default(data, {
1432
+ ...this.config
1433
+ });
1356
1434
  this.projectPaths.set(project.id, stateFilePath);
1357
1435
  return project;
1358
1436
  } catch (e) {
@@ -1360,6 +1438,10 @@ var Workspace = class {
1360
1438
  console.warn(e);
1361
1439
  }
1362
1440
  }).filter((s) => s);
1441
+ } else {
1442
+ this.logger.warn(
1443
+ `No projects found: directory at ${projectsPath} does not exist`
1444
+ );
1363
1445
  }
1364
1446
  }
1365
1447
  // TODO
@@ -1400,7 +1482,7 @@ import { randomUUID as randomUUID2 } from "node:crypto";
1400
1482
  import path4 from "node:path";
1401
1483
  import { readFileSync as readFileSync2 } from "node:fs";
1402
1484
  import { grammar } from "ohm-js";
1403
- import { isNil as isNil3, set } from "lodash-es";
1485
+ import { isNil as isNil4, set } from "lodash-es";
1404
1486
  var parser;
1405
1487
  var expectedNodeProps = [
1406
1488
  // TODO need to clarify adaptor/adaptors confusion
@@ -1571,7 +1653,7 @@ function generateWorkflow(def, options = {}) {
1571
1653
  if (options.uuidMap && raw.id in options.uuidMap) {
1572
1654
  uuid = options.uuidMap[raw.id];
1573
1655
  }
1574
- if (!isNil3(uuid) && options.openfnUuid) {
1656
+ if (!isNil4(uuid) && options.openfnUuid) {
1575
1657
  raw.openfn ??= {};
1576
1658
  raw.openfn.uuid = uuid;
1577
1659
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfn/project",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Read, serialize, replicate and sync OpenFn projects",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,8 +34,8 @@
34
34
  "lodash-es": "^4.17.21",
35
35
  "ohm-js": "^17.2.1",
36
36
  "yaml": "^2.2.2",
37
- "@openfn/lexicon": "^1.2.6",
38
- "@openfn/logger": "1.0.6"
37
+ "@openfn/lexicon": "^1.3.0",
38
+ "@openfn/logger": "1.1.0"
39
39
  },
40
40
  "files": [
41
41
  "dist",