retold-workflow 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.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/package.json +31 -0
- package/source/Retold-Workflow.js +32 -0
- package/source/Workflow-Service.js +160 -0
- package/source/Workflow-Type-Catalog.js +135 -0
- package/test/Workflow-Service_tests.js +144 -0
- package/test/Workflow-Type-Catalog_tests.js +180 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steven Velozo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# retold-workflow
|
|
2
|
+
|
|
3
|
+
A reusable, product-agnostic workflow capability for Retold, built on the
|
|
4
|
+
[fable-workflow](https://github.com/fable-retold/fable-workflow) engine.
|
|
5
|
+
|
|
6
|
+
It is the middle of three tiers:
|
|
7
|
+
|
|
8
|
+
- **fable-workflow** is the pure engine: workflow definitions, an append-only event
|
|
9
|
+
log, folded projections (time metrics and eligibility), guards, and agency queries.
|
|
10
|
+
- **retold-workflow** (this module) is the capability built on that engine: a workflow
|
|
11
|
+
service, a built-in/clone type catalog with provenance and drift, and the board,
|
|
12
|
+
timeline, metrics, and agency UI.
|
|
13
|
+
- **A product** supplies the concrete wiring: its tables, a few small stores, a context
|
|
14
|
+
resolver over its data, and any seeds.
|
|
15
|
+
|
|
16
|
+
The reason this is reusable rather than welded into one product is the same discipline
|
|
17
|
+
that makes the engine reusable: it depends only on injected interfaces, never on a
|
|
18
|
+
product's tables. A product implements an event store, a projection store, a
|
|
19
|
+
type-catalog store, and a context resolver over its own schema; a different product
|
|
20
|
+
implements the same four and gets the same workflow capability and the same UI, with no
|
|
21
|
+
new workflow code. The engine example already runs an editorial review and a hardware
|
|
22
|
+
return on one engine; this tier lets a whole product do the same.
|
|
23
|
+
|
|
24
|
+
## Status
|
|
25
|
+
|
|
26
|
+
In progress. Phase 1 is in: the type catalog and the workflow service. The UI is next.
|
|
27
|
+
|
|
28
|
+
## The type catalog
|
|
29
|
+
|
|
30
|
+
A catalog holds two kinds of workflow type:
|
|
31
|
+
|
|
32
|
+
- **built-in**: platform-owned archetypes (Software, Recipe, Physical Manufacturing).
|
|
33
|
+
Read-only and versioned.
|
|
34
|
+
- **owned**: a tenant's own types, either authored or deep-cloned from a built-in. A
|
|
35
|
+
clone records where it came from, so the platform can evolve a built-in without
|
|
36
|
+
disturbing anyone's running workflows, and a tenant can choose when to take the update.
|
|
37
|
+
|
|
38
|
+
`WorkflowTypeCatalog` is the generic logic over an injected, tenant-bound store. It does
|
|
39
|
+
not name a table or a customer.
|
|
40
|
+
|
|
41
|
+
```javascript
|
|
42
|
+
const { WorkflowTypeCatalog } = require('retold-workflow');
|
|
43
|
+
|
|
44
|
+
let tmpCatalog = new WorkflowTypeCatalog(myTenantBoundStore);
|
|
45
|
+
|
|
46
|
+
await tmpCatalog.unionList(); // built-ins (labeled) + this tenant's own types
|
|
47
|
+
await tmpCatalog.adoptBuiltIn(id); // lazy, idempotent: find-or-create the tenant's clone
|
|
48
|
+
await tmpCatalog.driftStatus(owned); // has the source built-in moved past this clone?
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Adopting a built-in is lazy and idempotent: the first time a tenant picks one it is
|
|
52
|
+
deep-cloned into an owned type stamped with its source and version; later picks of the
|
|
53
|
+
same built-in return that one clone.
|
|
54
|
+
|
|
55
|
+
### The store interface
|
|
56
|
+
|
|
57
|
+
A product implements this, already bound to the current tenant (all Promise-returning):
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
listBuiltIns() -> [typeRecord]
|
|
61
|
+
getBuiltIn(id) -> typeRecord | null
|
|
62
|
+
listOwnedTypes() -> [typeRecord]
|
|
63
|
+
findCloneOfBuiltIn(id) -> typeRecord | null
|
|
64
|
+
createOwnedType(record) -> typeRecord
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A `typeRecord` carries at least `{ ID, TypeKey, Name, Description, Version,
|
|
68
|
+
WorkflowDefinition, MetadataManifest, SourceID, SourceVersion }`. `WorkflowDefinition`
|
|
69
|
+
and `MetadataManifest` are arbitrary JSON the engine layer consumes; the catalog copies
|
|
70
|
+
them verbatim and never inspects them.
|
|
71
|
+
|
|
72
|
+
## The workflow service
|
|
73
|
+
|
|
74
|
+
`WorkflowService` drives a subject through its workflow, but it holds no state of its own.
|
|
75
|
+
The event log is the source of truth: every call loads the subject's log, rebuilds the
|
|
76
|
+
engine from it by replaying that log, runs the operation, and persists only the new events
|
|
77
|
+
plus a projection snapshot. A second service instance over the same stores sees the same
|
|
78
|
+
subject, which is what makes it safe behind a stateless server.
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
const { WorkflowService } = require('retold-workflow');
|
|
82
|
+
|
|
83
|
+
let tmpService = new WorkflowService(
|
|
84
|
+
{
|
|
85
|
+
eventStore, // listEvents(id) / appendEvents(id, events)
|
|
86
|
+
contextResolver, // (id) -> the data the subject's guards address into
|
|
87
|
+
definitionResolver, // (id) -> the workflow definition that governs the subject
|
|
88
|
+
projectionStore // optional: saveSnapshot(id, snap) / subjectsForActor(actor)
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await tmpService.open(id, actor);
|
|
92
|
+
await tmpService.advance(id, 'review', actor); // { ok, reason?, state? }, under role and data gates
|
|
93
|
+
await tmpService.reevaluate(id); // after the subject's data changed
|
|
94
|
+
await tmpService.getMetrics(id); // time in state, effort, active, overlap
|
|
95
|
+
await tmpService.whoCanActOn(id); // who has agency here, now
|
|
96
|
+
await tmpService.whatCanAdvance(actor); // which subjects this actor can move (indexed)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The product implements the stores over its own schema and the resolvers over its own data.
|
|
100
|
+
retold-workflow names none of it.
|
|
101
|
+
|
|
102
|
+
## Test
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
npm test
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "retold-workflow",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable, product-agnostic workflow capability for Retold, built on the fable-workflow engine. A workflow service, a built-in/clone type catalog with provenance and drift detection, and (in progress) board, timeline, metrics, and agency UI. It depends on injected stores and a context resolver, so it carries no opinion about a product's data model or persistence.",
|
|
5
|
+
"main": "source/Retold-Workflow.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "npx mocha -u tdd --exit --timeout 5000 test/*_tests.js",
|
|
8
|
+
"coverage": "npx nyc --reporter=lcov --reporter=text npm test"
|
|
9
|
+
},
|
|
10
|
+
"mocha": {
|
|
11
|
+
"ui": "tdd",
|
|
12
|
+
"reporter": "spec",
|
|
13
|
+
"timeout": "5000"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/fable-retold/retold-workflow.git"
|
|
18
|
+
},
|
|
19
|
+
"author": "steven velozo <steven@velozo.com>",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"fable-serviceproviderbase": "^3.0.19",
|
|
23
|
+
"fable-workflow": "^0.2.0",
|
|
24
|
+
"manyfest": "^1.0.49"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"fable": "^3.1.75",
|
|
28
|
+
"mocha": "^10.2.0",
|
|
29
|
+
"quackage": "^1.3.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* retold-workflow
|
|
5
|
+
*
|
|
6
|
+
* A reusable, product-agnostic workflow capability built on the fable-workflow engine.
|
|
7
|
+
* It is the middle tier of three:
|
|
8
|
+
*
|
|
9
|
+
* fable-workflow the pure engine (definitions, event log, projections, guards, agency)
|
|
10
|
+
* retold-workflow this module: a workflow service, a built-in/clone type catalog, and
|
|
11
|
+
* (in progress) board / timeline / metrics / agency UI
|
|
12
|
+
* <product> the concrete wiring: tables, stores, a context resolver, seeds
|
|
13
|
+
*
|
|
14
|
+
* It stays reusable by depending only on injected interfaces, never on a product's
|
|
15
|
+
* tables: an event store, a projection store, a type-catalog store, and a context
|
|
16
|
+
* resolver. A product implements those over its own schema; an editorial-review product
|
|
17
|
+
* and a manufacturing product implement the same four and get the same capability.
|
|
18
|
+
*
|
|
19
|
+
* Phase 1 ships the type catalog. The WorkflowService and the UI follow.
|
|
20
|
+
*
|
|
21
|
+
* @author Steven Velozo <steven@velozo.com>
|
|
22
|
+
* @license MIT
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const libWorkflowTypeCatalog = require('./Workflow-Type-Catalog.js');
|
|
26
|
+
const libWorkflowService = require('./Workflow-Service.js');
|
|
27
|
+
|
|
28
|
+
module.exports =
|
|
29
|
+
{
|
|
30
|
+
WorkflowTypeCatalog: libWorkflowTypeCatalog,
|
|
31
|
+
WorkflowService: libWorkflowService
|
|
32
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workflow-Service
|
|
5
|
+
*
|
|
6
|
+
* The product-agnostic core of retold-workflow. It drives a subject through its workflow
|
|
7
|
+
* on top of the fable-workflow engine, but it is stateless and persistence-backed: the
|
|
8
|
+
* event log is the source of truth, and the engine is rebuilt from it for each operation,
|
|
9
|
+
* then discarded. Nothing about a product's schema, tenancy, or storage leaks in;
|
|
10
|
+
* everything arrives through injected, already-tenant-bound interfaces.
|
|
11
|
+
*
|
|
12
|
+
* Injected (constructor config):
|
|
13
|
+
* eventStore { listEvents(subjectId) -> [event],
|
|
14
|
+
* appendEvents(subjectId, [event]) -> void } (required)
|
|
15
|
+
* contextResolver (subjectId) -> data object (Promise or value) (required)
|
|
16
|
+
* the data a subject's guards address into; fetched once per op
|
|
17
|
+
* definitionResolver (subjectId) -> WorkflowDefinition (Promise/value) (required)
|
|
18
|
+
* which workflow governs the subject (its type's definition)
|
|
19
|
+
* projectionStore { saveSnapshot(subjectId, snapshot) -> void,
|
|
20
|
+
* subjectsForActor(actor) -> [subjectId] } (optional)
|
|
21
|
+
* the materialized eligibility that whatCanAdvance reads
|
|
22
|
+
* now () -> ms timestamp (optional)
|
|
23
|
+
* engineClass a WorkflowEngine class (optional)
|
|
24
|
+
*
|
|
25
|
+
* Each write loads the subject's log, fetches its context once, builds a fresh engine,
|
|
26
|
+
* hydrates (or opens), runs the operation, and persists only the new events plus a
|
|
27
|
+
* projection snapshot. Reads hydrate and answer. Because state lives in the stores and
|
|
28
|
+
* not here, the same subject behaves identically whichever process touches it.
|
|
29
|
+
*
|
|
30
|
+
* @author Steven Velozo <steven@velozo.com>
|
|
31
|
+
* @license MIT
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const libFableWorkflow = require('fable-workflow');
|
|
35
|
+
|
|
36
|
+
class WorkflowService
|
|
37
|
+
{
|
|
38
|
+
constructor(pConfig)
|
|
39
|
+
{
|
|
40
|
+
let tmpConfig = pConfig || {};
|
|
41
|
+
if (!tmpConfig.eventStore) { throw new Error('WorkflowService requires an eventStore'); }
|
|
42
|
+
if (typeof tmpConfig.contextResolver !== 'function') { throw new Error('WorkflowService requires a contextResolver function'); }
|
|
43
|
+
if (typeof tmpConfig.definitionResolver !== 'function') { throw new Error('WorkflowService requires a definitionResolver function'); }
|
|
44
|
+
|
|
45
|
+
this._eventStore = tmpConfig.eventStore;
|
|
46
|
+
this._contextResolver = tmpConfig.contextResolver;
|
|
47
|
+
this._definitionResolver = tmpConfig.definitionResolver;
|
|
48
|
+
this._projectionStore = tmpConfig.projectionStore || null;
|
|
49
|
+
this._now = (typeof tmpConfig.now === 'function') ? tmpConfig.now : (() => Date.now());
|
|
50
|
+
this._EngineClass = tmpConfig.engineClass || libFableWorkflow.WorkflowEngine;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// -- writes ----------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
async open(pSubjectId, pActor, pAt)
|
|
56
|
+
{
|
|
57
|
+
let tmpExisting = await this._eventStore.listEvents(pSubjectId);
|
|
58
|
+
if (tmpExisting && tmpExisting.length) { throw new Error('subject "' + pSubjectId + '" is already open'); }
|
|
59
|
+
let tmpPrepared = await this._prepareEngine(pSubjectId);
|
|
60
|
+
tmpPrepared.engine.open(pSubjectId, tmpPrepared.definition.Key, pActor, pAt);
|
|
61
|
+
await this._persist(tmpPrepared, pSubjectId, 0);
|
|
62
|
+
return tmpPrepared.engine.getState(pSubjectId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async advance(pSubjectId, pToState, pActor, pAt)
|
|
66
|
+
{
|
|
67
|
+
let tmpPrepared = await this._hydrate(pSubjectId);
|
|
68
|
+
let tmpResult = tmpPrepared.engine.advance(pSubjectId, pToState, pActor, pAt);
|
|
69
|
+
if (tmpResult.ok) { await this._persist(tmpPrepared, pSubjectId, tmpPrepared.priorCount); }
|
|
70
|
+
return tmpResult;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async emit(pSubjectId, pEvent, pAt)
|
|
74
|
+
{
|
|
75
|
+
let tmpPrepared = await this._hydrate(pSubjectId);
|
|
76
|
+
tmpPrepared.engine.emit(pSubjectId, pEvent, pAt);
|
|
77
|
+
await this._persist(tmpPrepared, pSubjectId, tmpPrepared.priorCount);
|
|
78
|
+
return tmpPrepared.engine.getState(pSubjectId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async reevaluate(pSubjectId, pAt)
|
|
82
|
+
{
|
|
83
|
+
let tmpPrepared = await this._hydrate(pSubjectId);
|
|
84
|
+
tmpPrepared.engine.reevaluate(pSubjectId, pAt);
|
|
85
|
+
await this._persist(tmpPrepared, pSubjectId, tmpPrepared.priorCount);
|
|
86
|
+
return tmpPrepared.engine.getAvailableExits(pSubjectId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// -- reads -----------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
async getState(pSubjectId) { let tmpPrepared = await this._hydrate(pSubjectId); return tmpPrepared.engine.getState(pSubjectId); }
|
|
92
|
+
async getMetrics(pSubjectId) { let tmpPrepared = await this._hydrate(pSubjectId); return tmpPrepared.engine.getMetrics(pSubjectId); }
|
|
93
|
+
async getAvailableExits(pSubjectId) { let tmpPrepared = await this._hydrate(pSubjectId); return tmpPrepared.engine.getAvailableExits(pSubjectId); }
|
|
94
|
+
async whoCanActOn(pSubjectId) { let tmpPrepared = await this._hydrate(pSubjectId); return tmpPrepared.engine.whoCanActOn(pSubjectId); }
|
|
95
|
+
async getTimeline(pSubjectId) { let tmpEvents = await this._eventStore.listEvents(pSubjectId); return tmpEvents || []; }
|
|
96
|
+
|
|
97
|
+
/** Cross-subject agency. Delegates to the projection store's indexed query. */
|
|
98
|
+
async whatCanAdvance(pActor)
|
|
99
|
+
{
|
|
100
|
+
if (!this._projectionStore || typeof this._projectionStore.subjectsForActor !== 'function')
|
|
101
|
+
{
|
|
102
|
+
throw new Error('whatCanAdvance requires a projectionStore with a subjectsForActor query');
|
|
103
|
+
}
|
|
104
|
+
return this._projectionStore.subjectsForActor(pActor);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether an actor can take any satisfied exit in a stored snapshot. A projection store
|
|
109
|
+
* uses this (after its own indexed pre-filter) to answer subjectsForActor, so the agency
|
|
110
|
+
* rule lives in one place.
|
|
111
|
+
*/
|
|
112
|
+
static actorCanAct(pSnapshot, pActor)
|
|
113
|
+
{
|
|
114
|
+
let tmpActor = pActor || {};
|
|
115
|
+
let tmpEntitlements = tmpActor.Entitlements || [];
|
|
116
|
+
let tmpExits = (pSnapshot && pSnapshot.Eligibility) || [];
|
|
117
|
+
return tmpExits.some((pExit) => pExit.GuardSatisfied
|
|
118
|
+
&& (!pExit.RequiredEntitlement || tmpEntitlements.indexOf(pExit.RequiredEntitlement) >= 0)
|
|
119
|
+
&& (pExit.ResolvedActor == null || pExit.ResolvedActor === tmpActor.ID));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -- internals -------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
async _prepareEngine(pSubjectId)
|
|
125
|
+
{
|
|
126
|
+
let tmpDefinition = await this._definitionResolver(pSubjectId);
|
|
127
|
+
if (!tmpDefinition || !tmpDefinition.Key) { throw new Error('no workflow definition for subject "' + pSubjectId + '"'); }
|
|
128
|
+
let tmpContext = (await this._contextResolver(pSubjectId)) || {};
|
|
129
|
+
let tmpEngine = new this._EngineClass({ contextResolver: () => tmpContext, now: this._now });
|
|
130
|
+
tmpEngine.defineWorkflow(tmpDefinition);
|
|
131
|
+
return { engine: tmpEngine, definition: tmpDefinition };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async _hydrate(pSubjectId)
|
|
135
|
+
{
|
|
136
|
+
let tmpEvents = await this._eventStore.listEvents(pSubjectId);
|
|
137
|
+
if (!tmpEvents || !tmpEvents.length) { throw new Error('subject "' + pSubjectId + '" is not open'); }
|
|
138
|
+
let tmpPrepared = await this._prepareEngine(pSubjectId);
|
|
139
|
+
tmpPrepared.engine.hydrate(pSubjectId, tmpPrepared.definition.Key, tmpEvents);
|
|
140
|
+
tmpPrepared.priorCount = tmpPrepared.engine.getTimeline(pSubjectId).length;
|
|
141
|
+
return tmpPrepared;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async _persist(pPrepared, pSubjectId, pPriorCount)
|
|
145
|
+
{
|
|
146
|
+
let tmpDelta = pPrepared.engine.getTimeline(pSubjectId).slice(pPriorCount);
|
|
147
|
+
if (tmpDelta.length) { await this._eventStore.appendEvents(pSubjectId, tmpDelta); }
|
|
148
|
+
if (this._projectionStore && typeof this._projectionStore.saveSnapshot === 'function')
|
|
149
|
+
{
|
|
150
|
+
await this._projectionStore.saveSnapshot(pSubjectId,
|
|
151
|
+
{
|
|
152
|
+
State: pPrepared.engine.getState(pSubjectId),
|
|
153
|
+
Metrics: pPrepared.engine.getMetrics(pSubjectId),
|
|
154
|
+
Eligibility: pPrepared.engine.getAvailableExits(pSubjectId)
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = WorkflowService;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workflow-Type-Catalog
|
|
5
|
+
*
|
|
6
|
+
* The reusable built-in / clone type catalog, with provenance and drift, over an
|
|
7
|
+
* injected store. The store is the only coupling to a product's schema and tenancy:
|
|
8
|
+
* retold-workflow never names a table or a customer. A product hands in a store that
|
|
9
|
+
* is already bound to the current tenant, and this class adds the generic behavior.
|
|
10
|
+
*
|
|
11
|
+
* Two kinds of type live in the catalog:
|
|
12
|
+
* - built-in: platform-owned archetypes (Software, Recipe, ...). Read-only, versioned.
|
|
13
|
+
* - owned: a tenant's own types. Either authored, or a deep clone of a built-in
|
|
14
|
+
* that records where it came from (SourceID + SourceVersion).
|
|
15
|
+
*
|
|
16
|
+
* Adopting a built-in is lazy and idempotent: the first time a tenant picks one it is
|
|
17
|
+
* deep-cloned into an owned type; later picks of the same built-in return that one clone.
|
|
18
|
+
*
|
|
19
|
+
* Expected store interface (all Promise-returning), already bound to the current tenant:
|
|
20
|
+
* listBuiltIns() -> [typeRecord]
|
|
21
|
+
* getBuiltIn(pBuiltInID) -> typeRecord | null
|
|
22
|
+
* listOwnedTypes() -> [typeRecord]
|
|
23
|
+
* findCloneOfBuiltIn(pID) -> typeRecord | null
|
|
24
|
+
* createOwnedType(pRecord) -> typeRecord (the persisted row, with its new ID)
|
|
25
|
+
*
|
|
26
|
+
* A typeRecord is a plain object carrying at least:
|
|
27
|
+
* { ID, TypeKey, Name, Description, Version, WorkflowDefinition, MetadataManifest,
|
|
28
|
+
* SourceID, SourceVersion }
|
|
29
|
+
* ID and SourceID are opaque here. WorkflowDefinition and MetadataManifest are arbitrary
|
|
30
|
+
* JSON the engine layer consumes; this class copies them verbatim and never inspects them.
|
|
31
|
+
*
|
|
32
|
+
* @author Steven Velozo <steven@velozo.com>
|
|
33
|
+
* @license MIT
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const ORIGIN_BUILTIN = 'builtin';
|
|
37
|
+
const ORIGIN_OWNED = 'owned';
|
|
38
|
+
|
|
39
|
+
class WorkflowTypeCatalog
|
|
40
|
+
{
|
|
41
|
+
constructor(pStore)
|
|
42
|
+
{
|
|
43
|
+
if (!pStore) { throw new Error('WorkflowTypeCatalog requires a store'); }
|
|
44
|
+
this.store = pStore;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The picker list: built-ins (labeled) plus the tenant's own types. A built-in the
|
|
49
|
+
* tenant has already adopted carries AdoptedAsID pointing at its clone, so a product
|
|
50
|
+
* can show "already in use" instead of offering a second copy.
|
|
51
|
+
*/
|
|
52
|
+
async unionList()
|
|
53
|
+
{
|
|
54
|
+
let tmpBuiltIns = (await this.store.listBuiltIns()) || [];
|
|
55
|
+
let tmpOwned = (await this.store.listOwnedTypes()) || [];
|
|
56
|
+
|
|
57
|
+
let tmpOwnedBySource = {};
|
|
58
|
+
tmpOwned.forEach((pType) => { if (pType.SourceID != null) { tmpOwnedBySource[pType.SourceID] = pType; } });
|
|
59
|
+
|
|
60
|
+
let tmpList = [];
|
|
61
|
+
tmpBuiltIns.forEach((pType) =>
|
|
62
|
+
{
|
|
63
|
+
let tmpClone = tmpOwnedBySource[pType.ID];
|
|
64
|
+
tmpList.push(Object.assign({}, pType, { Origin: ORIGIN_BUILTIN, AdoptedAsID: tmpClone ? tmpClone.ID : null }));
|
|
65
|
+
});
|
|
66
|
+
tmpOwned.forEach((pType) =>
|
|
67
|
+
{
|
|
68
|
+
tmpList.push(Object.assign({}, pType, { Origin: ORIGIN_OWNED }));
|
|
69
|
+
});
|
|
70
|
+
return tmpList;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The tenant's own types only. */
|
|
74
|
+
async ownedTypes()
|
|
75
|
+
{
|
|
76
|
+
let tmpOwned = (await this.store.listOwnedTypes()) || [];
|
|
77
|
+
return tmpOwned.map((pType) => Object.assign({}, pType, { Origin: ORIGIN_OWNED }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Adopt a built-in: find-or-create the tenant's clone of it. Idempotent, so a second
|
|
82
|
+
* call for the same built-in returns the same clone. Returns the owned typeRecord.
|
|
83
|
+
*/
|
|
84
|
+
async adoptBuiltIn(pBuiltInID)
|
|
85
|
+
{
|
|
86
|
+
let tmpExisting = await this.store.findCloneOfBuiltIn(pBuiltInID);
|
|
87
|
+
if (tmpExisting) { return Object.assign({}, tmpExisting, { Origin: ORIGIN_OWNED }); }
|
|
88
|
+
|
|
89
|
+
let tmpBuiltIn = await this.store.getBuiltIn(pBuiltInID);
|
|
90
|
+
if (!tmpBuiltIn) { throw new Error('no built-in workflow type with id "' + pBuiltInID + '"'); }
|
|
91
|
+
|
|
92
|
+
let tmpClone =
|
|
93
|
+
{
|
|
94
|
+
TypeKey: tmpBuiltIn.TypeKey,
|
|
95
|
+
Name: tmpBuiltIn.Name,
|
|
96
|
+
Description: tmpBuiltIn.Description,
|
|
97
|
+
WorkflowDefinition: _deepCopy(tmpBuiltIn.WorkflowDefinition),
|
|
98
|
+
MetadataManifest: _deepCopy(tmpBuiltIn.MetadataManifest),
|
|
99
|
+
SourceID: tmpBuiltIn.ID,
|
|
100
|
+
SourceVersion: tmpBuiltIn.Version
|
|
101
|
+
};
|
|
102
|
+
let tmpCreated = await this.store.createOwnedType(tmpClone);
|
|
103
|
+
return Object.assign({}, tmpCreated, { Origin: ORIGIN_OWNED });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Drift of an owned type against the built-in it was cloned from. Returns
|
|
108
|
+
* { Drifted, SourceID, FromVersion, ToVersion }. Drifted is false for a type with no
|
|
109
|
+
* source (authored from scratch) or whose source built-in version is unchanged.
|
|
110
|
+
*/
|
|
111
|
+
async driftStatus(pOwnedType)
|
|
112
|
+
{
|
|
113
|
+
if (!pOwnedType || pOwnedType.SourceID == null)
|
|
114
|
+
{
|
|
115
|
+
return { Drifted: false, SourceID: null, FromVersion: null, ToVersion: null };
|
|
116
|
+
}
|
|
117
|
+
let tmpBuiltIn = await this.store.getBuiltIn(pOwnedType.SourceID);
|
|
118
|
+
if (!tmpBuiltIn)
|
|
119
|
+
{
|
|
120
|
+
return { Drifted: false, SourceID: pOwnedType.SourceID, FromVersion: pOwnedType.SourceVersion, ToVersion: null };
|
|
121
|
+
}
|
|
122
|
+
let tmpDrifted = Number(tmpBuiltIn.Version) > Number(pOwnedType.SourceVersion);
|
|
123
|
+
return { Drifted: tmpDrifted, SourceID: pOwnedType.SourceID, FromVersion: pOwnedType.SourceVersion, ToVersion: tmpBuiltIn.Version };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _deepCopy(pValue)
|
|
128
|
+
{
|
|
129
|
+
if (pValue === undefined || pValue === null) { return pValue; }
|
|
130
|
+
return JSON.parse(JSON.stringify(pValue));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = WorkflowTypeCatalog;
|
|
134
|
+
module.exports.ORIGIN_BUILTIN = ORIGIN_BUILTIN;
|
|
135
|
+
module.exports.ORIGIN_OWNED = ORIGIN_OWNED;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* retold-workflow - WorkflowService tests
|
|
5
|
+
*
|
|
6
|
+
* The service is exercised against in-memory event and projection stores standing in for a
|
|
7
|
+
* product's persistence. The point is that the service holds no state of its own: a second
|
|
8
|
+
* service instance built over the same stores sees the same subject, because the log is the
|
|
9
|
+
* source of truth and the engine is rebuilt from it on every call.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const libAssert = require('node:assert');
|
|
13
|
+
const libRetoldWorkflow = require('../source/Retold-Workflow.js');
|
|
14
|
+
const libWorkflowService = libRetoldWorkflow.WorkflowService;
|
|
15
|
+
|
|
16
|
+
// A deploy-pipeline definition (config). The service never names these fields.
|
|
17
|
+
function deployDefinition()
|
|
18
|
+
{
|
|
19
|
+
return {
|
|
20
|
+
Key: 'deploy',
|
|
21
|
+
Name: 'Deploy',
|
|
22
|
+
States: [ { Key: 'queued', IsInitial: true }, { Key: 'building' }, { Key: 'review' }, { Key: 'deployed', IsTerminal: true } ],
|
|
23
|
+
Transitions:
|
|
24
|
+
[
|
|
25
|
+
{ From: 'queued', To: 'building', RequiresEntitlement: 'build', Guard: { address: 'Change.HasTests', op: '==', value: true } },
|
|
26
|
+
{ From: 'building', To: 'review', RequiresEntitlement: 'build' },
|
|
27
|
+
{ From: 'review', To: 'deployed', RequiresEntitlement: 'deploy', Guard: { address: 'Change.Approved', op: '==', value: true } },
|
|
28
|
+
{ From: 'review', To: 'building', RequiresEntitlement: 'deploy' }
|
|
29
|
+
]
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class MemoryEventStore
|
|
34
|
+
{
|
|
35
|
+
constructor() { this.logs = {}; }
|
|
36
|
+
async listEvents(pID) { return (this.logs[pID] || []).map((pEvent) => Object.assign({}, pEvent)); }
|
|
37
|
+
async appendEvents(pID, pEvents) { if (!this.logs[pID]) { this.logs[pID] = []; } pEvents.forEach((pEvent) => this.logs[pID].push(Object.assign({}, pEvent))); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class MemoryProjectionStore
|
|
41
|
+
{
|
|
42
|
+
constructor() { this.snaps = {}; }
|
|
43
|
+
async saveSnapshot(pID, pSnap) { this.snaps[pID] = pSnap; }
|
|
44
|
+
async subjectsForActor(pActor) { return Object.keys(this.snaps).filter((pID) => libWorkflowService.actorCanAct(this.snaps[pID], pActor)); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeService(pChanges, pClock, pEventStore, pProjectionStore)
|
|
48
|
+
{
|
|
49
|
+
return new libWorkflowService(
|
|
50
|
+
{
|
|
51
|
+
eventStore: pEventStore,
|
|
52
|
+
projectionStore: pProjectionStore,
|
|
53
|
+
now: () => pClock.t,
|
|
54
|
+
contextResolver: (pID) => ({ Change: pChanges[pID] }),
|
|
55
|
+
definitionResolver: () => deployDefinition()
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
suite
|
|
60
|
+
(
|
|
61
|
+
'retold-workflow: WorkflowService',
|
|
62
|
+
() =>
|
|
63
|
+
{
|
|
64
|
+
test('requires the core stores and resolvers', () =>
|
|
65
|
+
{
|
|
66
|
+
libAssert.throws(() => new libWorkflowService({}), /eventStore/);
|
|
67
|
+
libAssert.throws(() => new libWorkflowService({ eventStore: {} }), /contextResolver/);
|
|
68
|
+
libAssert.throws(() => new libWorkflowService({ eventStore: {}, contextResolver: () => ({}) }), /definitionResolver/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('open persists the log and lands in the initial state', async () =>
|
|
72
|
+
{
|
|
73
|
+
let tmpEvents = new MemoryEventStore();
|
|
74
|
+
let tmpService = makeService({ c1: { HasTests: false } }, { t: 0 }, tmpEvents, new MemoryProjectionStore());
|
|
75
|
+
let tmpState = await tmpService.open('c1', { ID: 'jan', Entitlements: ['build'] }, 0);
|
|
76
|
+
libAssert.deepStrictEqual(tmpState.CurrentStates, ['queued']);
|
|
77
|
+
let tmpLog = await tmpEvents.listEvents('c1');
|
|
78
|
+
libAssert.ok(tmpLog.length >= 2, 'opened + state.enter persisted');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a guard blocks, then reevaluate plus advance moves and persists', async () =>
|
|
82
|
+
{
|
|
83
|
+
let tmpChanges = { c1: { HasTests: false } };
|
|
84
|
+
let tmpService = makeService(tmpChanges, { t: 0 }, new MemoryEventStore(), new MemoryProjectionStore());
|
|
85
|
+
await tmpService.open('c1', { ID: 'jan', Entitlements: ['build'] }, 0);
|
|
86
|
+
|
|
87
|
+
let tmpBlocked = await tmpService.advance('c1', 'building', { ID: 'jan', Entitlements: ['build'] }, 100);
|
|
88
|
+
libAssert.strictEqual(tmpBlocked.ok, false);
|
|
89
|
+
|
|
90
|
+
tmpChanges.c1.HasTests = true;
|
|
91
|
+
await tmpService.reevaluate('c1', 200);
|
|
92
|
+
let tmpReady = await tmpService.advance('c1', 'building', { ID: 'jan', Entitlements: ['build'] }, 300);
|
|
93
|
+
libAssert.strictEqual(tmpReady.ok, true);
|
|
94
|
+
libAssert.deepStrictEqual((await tmpService.getState('c1')).CurrentStates, ['building']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a second service over the same stores sees the same subject (stateless)', async () =>
|
|
98
|
+
{
|
|
99
|
+
let tmpChanges = { c1: { HasTests: true, Approved: true } };
|
|
100
|
+
let tmpEvents = new MemoryEventStore();
|
|
101
|
+
let tmpProjections = new MemoryProjectionStore();
|
|
102
|
+
let tmpFirst = makeService(tmpChanges, { t: 0 }, tmpEvents, tmpProjections);
|
|
103
|
+
await tmpFirst.open('c1', { ID: 'jan', Entitlements: ['build'] }, 0);
|
|
104
|
+
await tmpFirst.advance('c1', 'building', { ID: 'jan', Entitlements: ['build'] }, 1000);
|
|
105
|
+
await tmpFirst.advance('c1', 'review', { ID: 'jan', Entitlements: ['build'] }, 2000);
|
|
106
|
+
|
|
107
|
+
// a brand-new service instance, as if a different request or process
|
|
108
|
+
let tmpSecond = makeService(tmpChanges, { t: 0 }, tmpEvents, tmpProjections);
|
|
109
|
+
libAssert.deepStrictEqual((await tmpSecond.getState('c1')).CurrentStates, ['review']);
|
|
110
|
+
let tmpMetrics = await tmpSecond.getMetrics('c1');
|
|
111
|
+
libAssert.strictEqual(tmpMetrics.StateTime.queued, 1000);
|
|
112
|
+
libAssert.strictEqual(tmpMetrics.StateTime.building, 1000);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('agency: whoCanActOn per subject, whatCanAdvance via the projection store', async () =>
|
|
116
|
+
{
|
|
117
|
+
let tmpChanges = { c1: { HasTests: true, Approved: true }, c2: { HasTests: true, Approved: false } };
|
|
118
|
+
let tmpEvents = new MemoryEventStore();
|
|
119
|
+
let tmpProjections = new MemoryProjectionStore();
|
|
120
|
+
let tmpService = makeService(tmpChanges, { t: 0 }, tmpEvents, tmpProjections);
|
|
121
|
+
|
|
122
|
+
for (let pID of ['c1', 'c2'])
|
|
123
|
+
{
|
|
124
|
+
await tmpService.open(pID, { ID: 'jan', Entitlements: ['build'] }, 0);
|
|
125
|
+
await tmpService.advance(pID, 'building', { ID: 'jan', Entitlements: ['build'] }, 100);
|
|
126
|
+
await tmpService.advance(pID, 'review', { ID: 'jan', Entitlements: ['build'] }, 200);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let tmpDeployer = { ID: 'deb', Entitlements: ['deploy'] };
|
|
130
|
+
let tmpC1Exits = (await tmpService.whoCanActOn('c1')).map((pExit) => pExit.ToState);
|
|
131
|
+
libAssert.ok(tmpC1Exits.indexOf('deployed') >= 0, 'c1 is approved, so the deploy exit is ready');
|
|
132
|
+
|
|
133
|
+
let tmpAdvanceable = await tmpService.whatCanAdvance(tmpDeployer);
|
|
134
|
+
libAssert.ok(tmpAdvanceable.indexOf('c1') >= 0, 'deployer can deploy c1');
|
|
135
|
+
libAssert.ok(tmpAdvanceable.indexOf('c2') >= 0, 'deployer can reject c2 back to building');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('whatCanAdvance needs a projection store', async () =>
|
|
139
|
+
{
|
|
140
|
+
let tmpService = new libWorkflowService({ eventStore: new MemoryEventStore(), contextResolver: () => ({}), definitionResolver: () => deployDefinition() });
|
|
141
|
+
await libAssert.rejects(() => tmpService.whatCanAdvance({ ID: 'x', Entitlements: [] }), /projectionStore/);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* retold-workflow - Workflow-Type-Catalog tests
|
|
5
|
+
*
|
|
6
|
+
* The catalog is exercised against a small in-memory store standing in for a product's
|
|
7
|
+
* tenant-bound persistence. The point is that the catalog logic (union list, lazy clone,
|
|
8
|
+
* provenance, drift) is correct without any database, framework, or product schema.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const libAssert = require('node:assert');
|
|
12
|
+
const libWorkflowTypeCatalog = require('../source/Workflow-Type-Catalog.js');
|
|
13
|
+
|
|
14
|
+
// An in-memory store bound to a single tenant. Returns shallow copies on read (a real
|
|
15
|
+
// store hands back fresh rows), which is enough to prove the catalog deep-copies on clone.
|
|
16
|
+
class MemoryStore
|
|
17
|
+
{
|
|
18
|
+
constructor()
|
|
19
|
+
{
|
|
20
|
+
this.builtIns = [];
|
|
21
|
+
this.owned = [];
|
|
22
|
+
this._seq = 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
seedBuiltIn(pRecord)
|
|
26
|
+
{
|
|
27
|
+
let tmpRecord = Object.assign({ ID: 'b' + (++this._seq) }, pRecord);
|
|
28
|
+
this.builtIns.push(tmpRecord);
|
|
29
|
+
return tmpRecord;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async listBuiltIns() { return this.builtIns.map((pRow) => Object.assign({}, pRow)); }
|
|
33
|
+
async getBuiltIn(pID) { let tmpRow = this.builtIns.find((pRow) => pRow.ID === pID); return tmpRow ? Object.assign({}, tmpRow) : null; }
|
|
34
|
+
async listOwnedTypes() { return this.owned.map((pRow) => Object.assign({}, pRow)); }
|
|
35
|
+
async findCloneOfBuiltIn(pID) { let tmpRow = this.owned.find((pRow) => pRow.SourceID === pID); return tmpRow ? Object.assign({}, tmpRow) : null; }
|
|
36
|
+
async createOwnedType(pRecord) { let tmpRow = Object.assign({ ID: 'o' + (++this._seq) }, pRecord); this.owned.push(tmpRow); return Object.assign({}, tmpRow); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function softwareBuiltIn()
|
|
40
|
+
{
|
|
41
|
+
return {
|
|
42
|
+
TypeKey: 'software',
|
|
43
|
+
Name: 'Software',
|
|
44
|
+
Description: 'Software delivery lifecycle',
|
|
45
|
+
Version: 1,
|
|
46
|
+
WorkflowDefinition: { Key: 'software', States: [{ Key: 'backlog' }, { Key: 'done' }] },
|
|
47
|
+
MetadataManifest: { Fields: [{ Address: 'PR.Approved', Type: 'boolean' }] }
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
suite('retold-workflow: WorkflowTypeCatalog', () =>
|
|
52
|
+
{
|
|
53
|
+
suite('Construction', () =>
|
|
54
|
+
{
|
|
55
|
+
test('requires a store', () =>
|
|
56
|
+
{
|
|
57
|
+
libAssert.throws(() => new libWorkflowTypeCatalog(), /requires a store/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
suite('Union list', () =>
|
|
62
|
+
{
|
|
63
|
+
test('labels built-ins and owned, with no adoption yet', async () =>
|
|
64
|
+
{
|
|
65
|
+
let tmpStore = new MemoryStore();
|
|
66
|
+
tmpStore.seedBuiltIn(softwareBuiltIn());
|
|
67
|
+
let tmpCatalog = new libWorkflowTypeCatalog(tmpStore);
|
|
68
|
+
|
|
69
|
+
let tmpList = await tmpCatalog.unionList();
|
|
70
|
+
libAssert.strictEqual(tmpList.length, 1);
|
|
71
|
+
libAssert.strictEqual(tmpList[0].Origin, 'builtin');
|
|
72
|
+
libAssert.strictEqual(tmpList[0].AdoptedAsID, null);
|
|
73
|
+
libAssert.strictEqual(tmpList[0].TypeKey, 'software');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('after adoption the built-in row points at its clone', async () =>
|
|
77
|
+
{
|
|
78
|
+
let tmpStore = new MemoryStore();
|
|
79
|
+
let tmpBuiltIn = tmpStore.seedBuiltIn(softwareBuiltIn());
|
|
80
|
+
let tmpCatalog = new libWorkflowTypeCatalog(tmpStore);
|
|
81
|
+
|
|
82
|
+
let tmpClone = await tmpCatalog.adoptBuiltIn(tmpBuiltIn.ID);
|
|
83
|
+
let tmpList = await tmpCatalog.unionList();
|
|
84
|
+
|
|
85
|
+
let tmpBuiltInRow = tmpList.find((pRow) => pRow.Origin === 'builtin');
|
|
86
|
+
let tmpOwnedRow = tmpList.find((pRow) => pRow.Origin === 'owned');
|
|
87
|
+
libAssert.strictEqual(tmpBuiltInRow.AdoptedAsID, tmpClone.ID);
|
|
88
|
+
libAssert.ok(tmpOwnedRow, 'the owned clone is listed');
|
|
89
|
+
libAssert.strictEqual(tmpOwnedRow.SourceID, tmpBuiltIn.ID);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
suite('Adopt a built-in (lazy, idempotent clone)', () =>
|
|
94
|
+
{
|
|
95
|
+
test('creates a clone with provenance stamped', async () =>
|
|
96
|
+
{
|
|
97
|
+
let tmpStore = new MemoryStore();
|
|
98
|
+
let tmpBuiltIn = tmpStore.seedBuiltIn(softwareBuiltIn());
|
|
99
|
+
let tmpCatalog = new libWorkflowTypeCatalog(tmpStore);
|
|
100
|
+
|
|
101
|
+
let tmpClone = await tmpCatalog.adoptBuiltIn(tmpBuiltIn.ID);
|
|
102
|
+
libAssert.strictEqual(tmpClone.Origin, 'owned');
|
|
103
|
+
libAssert.strictEqual(tmpClone.SourceID, tmpBuiltIn.ID);
|
|
104
|
+
libAssert.strictEqual(tmpClone.SourceVersion, 1);
|
|
105
|
+
libAssert.strictEqual(tmpClone.TypeKey, 'software');
|
|
106
|
+
libAssert.deepStrictEqual(tmpClone.WorkflowDefinition, tmpBuiltIn.WorkflowDefinition);
|
|
107
|
+
libAssert.strictEqual(tmpStore.owned.length, 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a second adopt returns the same clone (find-or-create)', async () =>
|
|
111
|
+
{
|
|
112
|
+
let tmpStore = new MemoryStore();
|
|
113
|
+
let tmpBuiltIn = tmpStore.seedBuiltIn(softwareBuiltIn());
|
|
114
|
+
let tmpCatalog = new libWorkflowTypeCatalog(tmpStore);
|
|
115
|
+
|
|
116
|
+
let tmpFirst = await tmpCatalog.adoptBuiltIn(tmpBuiltIn.ID);
|
|
117
|
+
let tmpSecond = await tmpCatalog.adoptBuiltIn(tmpBuiltIn.ID);
|
|
118
|
+
libAssert.strictEqual(tmpFirst.ID, tmpSecond.ID);
|
|
119
|
+
libAssert.strictEqual(tmpStore.owned.length, 1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the clone deep-copies the definition (independent of the built-in)', async () =>
|
|
123
|
+
{
|
|
124
|
+
let tmpStore = new MemoryStore();
|
|
125
|
+
let tmpBuiltIn = tmpStore.seedBuiltIn(softwareBuiltIn());
|
|
126
|
+
let tmpCatalog = new libWorkflowTypeCatalog(tmpStore);
|
|
127
|
+
|
|
128
|
+
await tmpCatalog.adoptBuiltIn(tmpBuiltIn.ID);
|
|
129
|
+
// Mutate the stored built-in after the clone exists.
|
|
130
|
+
tmpStore.builtIns[0].WorkflowDefinition.States.push({ Key: 'injected' });
|
|
131
|
+
|
|
132
|
+
let tmpClone = await tmpStore.findCloneOfBuiltIn(tmpBuiltIn.ID);
|
|
133
|
+
libAssert.strictEqual(tmpClone.WorkflowDefinition.States.length, 2, 'clone unchanged by built-in mutation');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('adopting an unknown built-in throws', async () =>
|
|
137
|
+
{
|
|
138
|
+
let tmpStore = new MemoryStore();
|
|
139
|
+
let tmpCatalog = new libWorkflowTypeCatalog(tmpStore);
|
|
140
|
+
await libAssert.rejects(() => tmpCatalog.adoptBuiltIn('nope'), /no built-in workflow type/);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
suite('Drift', () =>
|
|
145
|
+
{
|
|
146
|
+
test('a fresh clone has not drifted', async () =>
|
|
147
|
+
{
|
|
148
|
+
let tmpStore = new MemoryStore();
|
|
149
|
+
let tmpBuiltIn = tmpStore.seedBuiltIn(softwareBuiltIn());
|
|
150
|
+
let tmpCatalog = new libWorkflowTypeCatalog(tmpStore);
|
|
151
|
+
|
|
152
|
+
let tmpClone = await tmpCatalog.adoptBuiltIn(tmpBuiltIn.ID);
|
|
153
|
+
let tmpDrift = await tmpCatalog.driftStatus(tmpClone);
|
|
154
|
+
libAssert.strictEqual(tmpDrift.Drifted, false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('bumping the built-in version drifts the clone', async () =>
|
|
158
|
+
{
|
|
159
|
+
let tmpStore = new MemoryStore();
|
|
160
|
+
let tmpBuiltIn = tmpStore.seedBuiltIn(softwareBuiltIn());
|
|
161
|
+
let tmpCatalog = new libWorkflowTypeCatalog(tmpStore);
|
|
162
|
+
|
|
163
|
+
let tmpClone = await tmpCatalog.adoptBuiltIn(tmpBuiltIn.ID);
|
|
164
|
+
tmpStore.builtIns[0].Version = 2;
|
|
165
|
+
|
|
166
|
+
let tmpDrift = await tmpCatalog.driftStatus(tmpClone);
|
|
167
|
+
libAssert.strictEqual(tmpDrift.Drifted, true);
|
|
168
|
+
libAssert.strictEqual(tmpDrift.FromVersion, 1);
|
|
169
|
+
libAssert.strictEqual(tmpDrift.ToVersion, 2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('an authored type (no source) never drifts', async () =>
|
|
173
|
+
{
|
|
174
|
+
let tmpStore = new MemoryStore();
|
|
175
|
+
let tmpCatalog = new libWorkflowTypeCatalog(tmpStore);
|
|
176
|
+
let tmpDrift = await tmpCatalog.driftStatus({ ID: 'o9', TypeKey: 'custom', SourceID: null });
|
|
177
|
+
libAssert.strictEqual(tmpDrift.Drifted, false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|