@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 +12 -0
- package/dist/index.d.ts +35 -2
- package/dist/index.js +110 -33
- package/package.json +2 -2
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.
|
|
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.
|
|
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.
|
|
394
|
+
wfState.edges[`${s.id}->${next}`] = e;
|
|
392
395
|
});
|
|
393
396
|
});
|
|
394
|
-
wfState.edges =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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(
|
|
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(
|
|
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: {
|
|
957
|
-
|
|
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.
|
|
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.
|
|
37
|
+
"@openfn/lexicon": "^1.4.0",
|
|
38
38
|
"@openfn/logger": "1.1.1"
|
|
39
39
|
},
|
|
40
40
|
"files": [
|