@openfn/project 0.1.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.
@@ -0,0 +1,44 @@
1
+ import * as l from '@openfn/lexicon';
2
+
3
+ type FileFormats = 'yaml' | 'json';
4
+ type RepoOptions = {
5
+ /**default workflow root when serializing to fs (relative to openfn.yaml) */
6
+ workflowRoot?: string;
7
+ formats: {
8
+ openfn: FileFormats;
9
+ workflow: FileFormats;
10
+ project: FileFormats;
11
+ };
12
+ };
13
+ declare class Project {
14
+ /** project name */
15
+ name?: string;
16
+ description?: string;
17
+ history: string[];
18
+ workflows: l.Workflow[];
19
+ options: any;
20
+ meta: any;
21
+ openfn?: l.ProjectConfig;
22
+ repo?: Required<RepoOptions>;
23
+ static from(type: 'state', data: any, options: Partial<l.ProjectConfig>): Project;
24
+ static from(type: 'fs', options: {
25
+ root: string;
26
+ }): Project;
27
+ static from(type: 'path', data: any): Project;
28
+ static diff(a: Project, b: Project): void;
29
+ constructor(data: l.Project, repoConfig?: RepoOptions);
30
+ serialize(type?: 'json' | 'yaml' | 'fs' | 'state', options?: any): any;
31
+ getVersionHash(): void;
32
+ merge(project: Project, options: any): void;
33
+ getWorkflow(id: string): l.Workflow | undefined;
34
+ getIdentifier(): string;
35
+ compare(proj: Project): void;
36
+ getUUID(workflow: string | Workflow, stepId: string, otherStep?: string): any;
37
+ }
38
+ declare class Workflow {
39
+ }
40
+
41
+ declare function yamlToJson(y: string): any;
42
+ declare function jsonToYaml(json: string | JSONObject): string;
43
+
44
+ export { Project as default, jsonToYaml, yamlToJson };
package/dist/index.js ADDED
@@ -0,0 +1,562 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
7
+ // src/serialize/index.ts
8
+ var serialize_exports = {};
9
+ __export(serialize_exports, {
10
+ fs: () => to_fs_default,
11
+ json: () => to_json_default,
12
+ state: () => to_app_state_default
13
+ });
14
+
15
+ // src/serialize/to-json.ts
16
+ function to_json_default(project) {
17
+ return {
18
+ // There must be a better way to do this?
19
+ // Do we just serialize all public fields?
20
+ name: project.name,
21
+ description: project.description,
22
+ repo: project.repo,
23
+ meta: project.meta,
24
+ workflows: project.workflows,
25
+ collections: project.collections,
26
+ credentials: project.credentials,
27
+ openfn: project.openfn,
28
+ options: project.options
29
+ };
30
+ }
31
+
32
+ // src/util/yaml.ts
33
+ import yaml from "yaml";
34
+ function yamlToJson(y) {
35
+ const doc = yaml.parseDocument(y);
36
+ return doc.toJS();
37
+ }
38
+ function jsonToYaml(json) {
39
+ if (typeof json === "string") {
40
+ json = JSON.parse(json);
41
+ }
42
+ const doc = new yaml.Document(json);
43
+ return yaml.stringify(doc, null, 2);
44
+ }
45
+
46
+ // src/serialize/to-app-state.ts
47
+ import { randomUUID } from "node:crypto";
48
+ function to_app_state_default(project, options = {}) {
49
+ const { projectId: id, endpoint, env, ...rest } = project.openfn;
50
+ const state = {
51
+ id,
52
+ name: project.name,
53
+ description: project.description,
54
+ project_credentials: project.credentials,
55
+ collections: project.collections,
56
+ ...rest,
57
+ ...project.options,
58
+ workflows: project.workflows.map(mapWorkflow)
59
+ };
60
+ const shouldReturnYaml = options.format === "yaml" || !options.format && project.repo.formats.project === "yaml";
61
+ if (shouldReturnYaml) {
62
+ return jsonToYaml(state);
63
+ }
64
+ return state;
65
+ }
66
+ var mapWorkflow = (workflow) => {
67
+ const wfState = {
68
+ name: workflow.name,
69
+ ...workflow.openfn,
70
+ jobs: [],
71
+ triggers: [],
72
+ edges: []
73
+ };
74
+ const lookup = workflow.steps.reduce((obj, next) => {
75
+ if (!next.openfn?.id) {
76
+ next.openfn ??= {};
77
+ next.openfn.id = randomUUID();
78
+ }
79
+ obj[next.id] = next.openfn.id;
80
+ return obj;
81
+ }, {});
82
+ workflow.steps.forEach((s) => {
83
+ let isTrigger;
84
+ let node;
85
+ if (s.type && !s.expression) {
86
+ isTrigger = true;
87
+ node = {
88
+ type: s.type,
89
+ ...s.openfn
90
+ };
91
+ wfState.triggers.push(node);
92
+ } else {
93
+ node = {
94
+ name: s.name,
95
+ body: s.expression,
96
+ adaptor: s.adaptor,
97
+ ...s.openfn
98
+ };
99
+ wfState.jobs.push(node);
100
+ }
101
+ Object.keys(s.next ?? {}).forEach((next) => {
102
+ const rules = s.next[next];
103
+ const e = {
104
+ id: rules.openfn?.id ?? randomUUID(),
105
+ target_job_id: lookup[next],
106
+ enabled: !rules.disabled
107
+ };
108
+ if (isTrigger) {
109
+ e.source_trigger_id = node.id;
110
+ } else {
111
+ e.source_job_id = node.id;
112
+ }
113
+ if (rules.condition === true) {
114
+ e.condition_type = "always";
115
+ } else if (rules.condition === false) {
116
+ e.condition_type = "never";
117
+ } else if (typeof rules.condition === "string") {
118
+ }
119
+ wfState.edges.push(e);
120
+ });
121
+ });
122
+ return wfState;
123
+ };
124
+
125
+ // src/serialize/to-fs.ts
126
+ import nodepath from "path";
127
+ var stringify = (json) => JSON.stringify(json, null, 2);
128
+ function to_fs_default(project) {
129
+ const files = {};
130
+ const { path: path2, content } = extractRepoConfig(project);
131
+ files[path2] = content;
132
+ for (const wf of project.workflows) {
133
+ const { path: path3, content: content2 } = extractWorkflow(project, wf.id);
134
+ files[path3] = content2;
135
+ for (const s of wf.steps) {
136
+ const result = extractStep(project, wf.id, s.id);
137
+ if (result) {
138
+ const { path: path4, content: content3 } = result;
139
+ files[path4] = content3;
140
+ }
141
+ }
142
+ }
143
+ return files;
144
+ }
145
+ var extractWorkflow = (project, workflowId2) => {
146
+ const format = project.repo.formats.workflow;
147
+ const workflow = project.getWorkflow(workflowId2);
148
+ if (!workflow) {
149
+ throw new Error(`workflow not found: ${workflowId2}`);
150
+ }
151
+ const root = project.repo?.workflowRoot ?? "workflows/";
152
+ const path2 = nodepath.join(root, workflow.id, workflow.id);
153
+ const wf = {
154
+ id: workflow.id,
155
+ name: workflow.name,
156
+ options: workflow.options,
157
+ steps: workflow.steps.map((step) => {
158
+ const { openfn, expression, ...mapped } = step;
159
+ if (expression) {
160
+ mapped.expression = `./${step.id}.js`;
161
+ }
162
+ return mapped;
163
+ })
164
+ };
165
+ return handleOutput(wf, path2, format);
166
+ };
167
+ var extractStep = (project, workflowId2, stepId) => {
168
+ const workflow = project.getWorkflow(workflowId2);
169
+ if (!workflow) {
170
+ throw new Error(`workflow not found: ${workflowId2}`);
171
+ }
172
+ const step = workflow.steps.find((s) => s.id === stepId);
173
+ if (!step) {
174
+ throw new Error(`step not found: ${stepId}`);
175
+ }
176
+ if (step.expression) {
177
+ const root = project.config?.workflowRoot ?? "workflows/";
178
+ const path2 = nodepath.join(root, `${workflow.id}/${step.id}.js`);
179
+ const content = step.expression;
180
+ return { path: path2, content };
181
+ }
182
+ };
183
+ var extractRepoConfig = (project) => {
184
+ const format = project.repo.formats.openfn;
185
+ const config = {
186
+ name: project.name,
187
+ ...project.repo,
188
+ project: project.openfn ?? {}
189
+ };
190
+ return handleOutput(config, "openfn", format);
191
+ };
192
+ var handleOutput = (data, filePath, format) => {
193
+ const path2 = `${filePath}.${format}`;
194
+ let content;
195
+ if (format === "json") {
196
+ content = stringify(data, null, 2);
197
+ } else if (format === "yaml") {
198
+ content = jsonToYaml(data);
199
+ } else {
200
+ throw new Error(`Unrecognised format: ${format}`);
201
+ }
202
+ return { path: path2, content };
203
+ };
204
+
205
+ // src/parse/from-app-state.ts
206
+ function slugify(text) {
207
+ return text.replace(/\W/g, " ").trim().replace(/\s+/g, "-").toLowerCase();
208
+ }
209
+ var from_app_state_default = (state, config) => {
210
+ if (config.format === "yaml") {
211
+ state = yamlToJson(state);
212
+ } else if (typeof state === "string") {
213
+ state = JSON.parse(state);
214
+ }
215
+ const {
216
+ id,
217
+ name,
218
+ description,
219
+ workflows,
220
+ project_credentials: credentials,
221
+ collections,
222
+ inserted_at,
223
+ updated_at,
224
+ ...options
225
+ } = state;
226
+ const proj = {
227
+ name,
228
+ // TODO do we need to slug this or anything?
229
+ description,
230
+ collections,
231
+ credentials,
232
+ options
233
+ };
234
+ const repoConfig = {};
235
+ proj.openfn = {
236
+ projectId: id,
237
+ endpoint: config.endpoint,
238
+ env: config.env,
239
+ inserted_at,
240
+ updated_at
241
+ };
242
+ proj.meta = {
243
+ fetched_at: config.fetchedAt
244
+ };
245
+ proj.workflows = state.workflows.map(mapWorkflow2);
246
+ return new Project(proj, repoConfig);
247
+ };
248
+ var mapTriggerEdgeCondition = (edge) => {
249
+ const e = {
250
+ disabled: !edge.enabled
251
+ };
252
+ if (edge.condition_type === "always") {
253
+ e.condition = true;
254
+ } else if (edge.condition_type === "never") {
255
+ e.condition = false;
256
+ } else {
257
+ e.condition = edge.condition_expression;
258
+ }
259
+ e.openfn = {
260
+ id: edge.id
261
+ };
262
+ return e;
263
+ };
264
+ var mapWorkflow2 = (workflow) => {
265
+ const { jobs, edges, triggers, name, ...remoteProps } = workflow;
266
+ const mapped = {
267
+ id: slugify(workflow.name),
268
+ name: workflow.name,
269
+ steps: [],
270
+ openfn: remoteProps
271
+ };
272
+ workflow.triggers.forEach((trigger) => {
273
+ const { type, ...otherProps } = trigger;
274
+ const connectedEdges = edges.filter(
275
+ (e) => e.source_trigger_id === trigger.id
276
+ );
277
+ mapped.steps.push({
278
+ id: "trigger",
279
+ type,
280
+ openfn: otherProps,
281
+ next: connectedEdges.reduce((obj, edge) => {
282
+ const target = jobs.find((j) => j.id === edge.target_job_id);
283
+ if (!target) {
284
+ throw new Error(`Failed to find ${edge.target_job_id}`);
285
+ }
286
+ obj[slugify(target.name)] = mapTriggerEdgeCondition(edge);
287
+ return obj;
288
+ }, {})
289
+ });
290
+ });
291
+ workflow.jobs.forEach((step) => {
292
+ const outboundEdges = edges.filter(
293
+ (e) => e.source_job_id === step.id || e.source_trigger_id === step.id
294
+ );
295
+ const { body: expression, name: name2, adaptor, ...remoteProps2 } = step;
296
+ const s = {
297
+ id: slugify(name2),
298
+ name: name2,
299
+ expression,
300
+ adaptor,
301
+ openfn: remoteProps2
302
+ };
303
+ if (outboundEdges.length) {
304
+ s.next = outboundEdges.reduce((next, edge) => {
305
+ const target = jobs.find((j) => j.id === edge.target_job_id);
306
+ next[slugify(target.name)] = mapTriggerEdgeCondition(edge);
307
+ return next;
308
+ }, {});
309
+ }
310
+ mapped.steps.push(s);
311
+ });
312
+ return mapped;
313
+ };
314
+
315
+ // src/parse/from-fs.ts
316
+ import fs from "node:fs/promises";
317
+ import path from "node:path";
318
+ import { glob } from "glob";
319
+
320
+ // src/util/get-identifier.ts
321
+ var get_identifier_default = (config = {}) => {
322
+ const endpoint = config.endpoint || "local";
323
+ const name = config.env ?? "main";
324
+ let host;
325
+ try {
326
+ host = new URL(endpoint).hostname;
327
+ } catch (e) {
328
+ host = endpoint;
329
+ }
330
+ return `${name}@${host}`;
331
+ };
332
+
333
+ // src/parse/from-fs.ts
334
+ var parseProject = async (options = {}) => {
335
+ const { root } = options;
336
+ const proj = {};
337
+ let config;
338
+ try {
339
+ const file = await fs.readFile(
340
+ path.resolve(path.join(root, "openfn.yaml")),
341
+ "utf8"
342
+ );
343
+ config = yamlToJson(file);
344
+ } catch (e) {
345
+ try {
346
+ const file = await fs.readFile(
347
+ path.join(root || ".", "openfn.json"),
348
+ "utf8"
349
+ );
350
+ config = JSON.parse(file);
351
+ } catch (e2) {
352
+ console.log(e2);
353
+ throw e2;
354
+ }
355
+ }
356
+ let state;
357
+ const identifier = get_identifier_default({
358
+ endpoint: config.project?.endpoint,
359
+ env: config.project?.env
360
+ });
361
+ try {
362
+ const format = config.formats.project ?? "yaml";
363
+ const statePath = path.join(root, ".projects", `${identifier}.${format}`);
364
+ const stateFile = await fs.readFile(statePath, "utf8");
365
+ state = from_app_state_default(stateFile, { format });
366
+ } catch (e) {
367
+ console.warn(`Failed to find state file for ${identifier}`);
368
+ }
369
+ const { project: openfn, ...repo } = config;
370
+ proj.openfn = openfn;
371
+ const workflowDir = config.workflowRoot ?? "workflows";
372
+ const fileType = config.formats?.workflow ?? "yaml";
373
+ const pattern = `${root}/${workflowDir}/*/*.${fileType}`;
374
+ const candidateWfs = await glob(pattern, {
375
+ ignore: ["**node_modules/**", "**tmp**"]
376
+ });
377
+ const workflows = [];
378
+ for (const filePath of candidateWfs) {
379
+ const candidate = await fs.readFile(filePath, "utf-8");
380
+ try {
381
+ const wf = fileType === "yaml" ? yamlToJson(candidate) : JSON.parse(candidate);
382
+ if (wf.id && Array.isArray(wf.steps)) {
383
+ const wfState = (state && state.getWorkflow(wf.id)) ?? {};
384
+ wf.openfn = {
385
+ id: wfState.openfn?.id ?? null
386
+ // TODO do we need to transfer more stuff?
387
+ };
388
+ console.log("Loading workflow at ", filePath);
389
+ for (const step of wf.steps) {
390
+ if (step.expression && step.expression.endsWith(".js")) {
391
+ const dir = path.dirname(filePath);
392
+ const exprPath = path.join(dir, step.expression);
393
+ try {
394
+ console.debug(`Loaded expression from ${exprPath}`);
395
+ step.expression = await fs.readFile(exprPath, "utf-8");
396
+ } catch (e) {
397
+ console.error(`Error loading expression from ${exprPath}`);
398
+ }
399
+ }
400
+ const uuid = state?.getUUID(wf.id, step.id) ?? null;
401
+ step.openfn = { id: uuid };
402
+ for (const target in step.next || {}) {
403
+ if (typeof step.next[target] === "boolean") {
404
+ const bool = step.next[target];
405
+ step.next[target] = { condition: bool };
406
+ }
407
+ const uuid2 = state?.getUUID(wf.id, step.id, target) ?? null;
408
+ step.next[target].openfn = { id: uuid2 };
409
+ }
410
+ }
411
+ workflows.push(wf);
412
+ }
413
+ } catch (e) {
414
+ console.log(e);
415
+ continue;
416
+ }
417
+ }
418
+ proj.workflows = workflows;
419
+ return new Project(proj, repo);
420
+ };
421
+
422
+ // src/util/uuid.ts
423
+ var getUuidForStep = (project, workflow, stepId) => {
424
+ const wf = typeof workflow === "string" ? project.getWorkflow(workflow) : workflow;
425
+ if (!wf) {
426
+ throw new Error(`Workflow "${workflow} not found in project ${project.id}`);
427
+ }
428
+ for (const step of wf.steps) {
429
+ if (step.id === stepId) {
430
+ return step.openfn?.id ?? null;
431
+ }
432
+ }
433
+ return null;
434
+ };
435
+ var getUuidForEdge = (project, workflow, from, to) => {
436
+ const wf = typeof workflow === "string" ? project.getWorkflow(workflow) : workflow;
437
+ if (!wf) {
438
+ throw new Error(
439
+ `Workflow "${workflowId} not found in project ${project.id}`
440
+ );
441
+ }
442
+ for (const step of wf.steps) {
443
+ if (step.id === from) {
444
+ for (const edge in step.next) {
445
+ if (edge === to) {
446
+ return step.next[edge].openfn?.id ?? null;
447
+ }
448
+ }
449
+ break;
450
+ }
451
+ }
452
+ return null;
453
+ };
454
+
455
+ // src/Project.ts
456
+ var setConfigDefaults = (config = {}) => ({
457
+ workflowRoot: config.workflowRoot ?? "workflows",
458
+ formats: {
459
+ // TODO change these maybe
460
+ openfn: config.formats?.openfn ?? "yaml",
461
+ project: config.formats?.project ?? "yaml",
462
+ workflow: config.formats?.workflow ?? "yaml"
463
+ }
464
+ });
465
+ var Project = class {
466
+ // what schema version is this?
467
+ // And how are we tracking this?
468
+ // version;
469
+ /** project name */
470
+ name;
471
+ description;
472
+ // array of version shas
473
+ history = [];
474
+ workflows;
475
+ // option strings saved by the app
476
+ // these are all (?) unused clientside
477
+ options;
478
+ // local metadata used by the CLI
479
+ // This stuff is not synced back to lightning
480
+ meta;
481
+ // this contains meta about the connected openfn project
482
+ openfn;
483
+ // repo configuration options
484
+ // these should be shared across projects
485
+ // and saved to an openfn.yaml file
486
+ repo;
487
+ static from(type, data, options) {
488
+ if (type === "state") {
489
+ return from_app_state_default(data, options);
490
+ }
491
+ if (type === "fs") {
492
+ return parseProject(data, options);
493
+ }
494
+ throw new Error(`Didn't recognize type ${type}`);
495
+ }
496
+ // Diff two projects
497
+ static diff(a, b) {
498
+ }
499
+ // env is excluded because it's not really part of the project
500
+ // uh maybe
501
+ // maybe this second arg is config - like env, branch rules, serialisation rules
502
+ // stuff that's external to the actual project and managed by the repo
503
+ constructor(data, repoConfig = {}) {
504
+ this.repo = setConfigDefaults(repoConfig);
505
+ this.id = data.id;
506
+ this.name = data.name;
507
+ this.description = data.description;
508
+ this.openfn = data.openfn;
509
+ this.options = data.options;
510
+ this.workflows = data.workflows;
511
+ this.collections = data.collections;
512
+ this.credentials = data.credentials;
513
+ this.meta = data.meta;
514
+ }
515
+ serialize(type = "json", options) {
516
+ if (type in serialize_exports) {
517
+ return serialize_exports[type](this, options);
518
+ }
519
+ throw new Error(`Cannot serialize ${type}`);
520
+ }
521
+ // would like a better name for this
522
+ // stamp? id? sha?
523
+ // this builds a version string for the current state
524
+ getVersionHash() {
525
+ }
526
+ // take a second project and merge its data into this one
527
+ // Throws if there's a conflict, unless force is true
528
+ // It's basically an overwrite
529
+ merge(project, options) {
530
+ }
531
+ // what else might we need?
532
+ // get workflow by name or id
533
+ // this is fuzzy, but is that wrong?
534
+ getWorkflow(id) {
535
+ return this.workflows.find((wf) => wf.id == id);
536
+ }
537
+ // it's the name of the project.yaml file
538
+ // qualified name? Remote name? App name?
539
+ // every project in a repo need a unique identifier
540
+ getIdentifier() {
541
+ return get_identifier_default(this.openfn);
542
+ }
543
+ // Compare this project with another and return a diff
544
+ compare(proj) {
545
+ }
546
+ // find the UUID for a given node or edge
547
+ // returns null if it doesn't exist
548
+ getUUID(workflow, stepId, otherStep) {
549
+ if (otherStep) {
550
+ return getUuidForEdge(this, workflow, stepId, otherStep);
551
+ }
552
+ return getUuidForStep(this, workflow, stepId);
553
+ }
554
+ };
555
+
556
+ // src/index.ts
557
+ var src_default = Project;
558
+ export {
559
+ src_default as default,
560
+ jsonToYaml,
561
+ yamlToJson
562
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@openfn/project",
3
+ "version": "0.1.0",
4
+ "description": "Read, serialize, replicate and sync OpenFn projects",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "import": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ }
13
+ },
14
+ "module": "dist/index.js",
15
+ "types": "dist/index.d.ts",
16
+ "keywords": [],
17
+ "author": "Open Function Group <admin@openfn.org>",
18
+ "license": "ISC",
19
+ "devDependencies": {
20
+ "ava": "5.3.1",
21
+ "mock-fs": "^5.4.1",
22
+ "rimraf": "^3.0.2",
23
+ "tslib": "^2.4.0",
24
+ "tsup": "^7.2.0",
25
+ "typescript": "^5.1.6"
26
+ },
27
+ "dependencies": {
28
+ "glob": "^11.0.2",
29
+ "yaml": "^2.2.2",
30
+ "@openfn/lexicon": "^1.2.2",
31
+ "@openfn/logger": "1.0.5"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md"
36
+ ],
37
+ "scripts": {
38
+ "test": "pnpm ava",
39
+ "test:watch": "pnpm ava -w",
40
+ "test:types": "pnpm tsc --noEmit --project tsconfig.json",
41
+ "build": "tsup --config ../../tsup.config.js src/index.ts",
42
+ "build:watch": "pnpm build --watch",
43
+ "pack": "pnpm pack --pack-destination ../../dist"
44
+ }
45
+ }