Skip to content

Commit

Permalink
Fully parameterized state machine
Browse files Browse the repository at this point in the history
  • Loading branch information
FyreByrd committed Sep 23, 2024
1 parent c9cbd78 commit 4b79a9d
Show file tree
Hide file tree
Showing 8 changed files with 1,208 additions and 629 deletions.
2 changes: 1 addition & 1 deletion source/SIL.AppBuilder.Portal/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ export * as BullMQ from './BullJobTypes.js';
export { scriptoriaQueue } from './bullmq.js';
export { default as DatabaseWrites } from './databaseProxy/index.js';
export { readonlyPrisma as prisma } from './prisma.js';
export { NoAdminS3, getSnapshot } from './workflow/index.js';
export { DefaultWorkflow, getSnapshot } from './workflow/index.js';
152 changes: 140 additions & 12 deletions source/SIL.AppBuilder.Portal/common/public/workflow.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,128 @@
import type {
AnyEventObject,
StateMachineDefinition,
StateMachine,
TransitionDefinition,
StateNode as XStateNode
} from 'xstate';
import type { RoleId } from './prisma.js';

export enum ActionType {
Auto = 0,
User
}

export enum AdminLevel {
/** NoAdmin/OwnerAdmin */
None = 0,
/** LowAdmin */
Low,
/** Approval required */
High
}

export enum ProductType {
Android_GooglePlay = 0,
Android_S3,
AssetPackage,
Web
}

export type StateName =
| 'Readiness Check'
| 'Approval'
| 'Approval Pending'
| 'Terminated'
| 'App Builder Configuration'
| 'Author Configuration'
| 'Synchronize Data'
| 'Author Download'
| 'Author Upload'
| 'Product Build'
| 'Set Google Play Existing'
| 'App Store Preview'
| 'Create App Store Entry'
| 'Set Google Play Uploaded'
| 'Verify and Publish'
| 'Email Reviewers'
| 'Product Publish'
| 'Make It Live'
| 'Published'
| 'Product Creation';

export type WorkflowContext = {
//later: narrow types if necessary
instructions: string;
includeFields: string[];
instructions:
| 'asset_package_verify_and_publish'
| 'app_configuration'
| 'create_app_entry'
| 'authors_download'
| 'googleplay_configuration'
| 'googleplay_verify_and_publish'
| 'make_it_live'
| 'approval_pending'
| 'readiness_check'
| 'synchronize_data'
| 'authors_upload'
| 'verify_and_publish'
| 'waiting'
| 'web_verify'
| null;
includeFields: (
| 'ownerName'
| 'ownerEmail'
| 'storeDescription'
| 'listingLanguageCode'
| 'projectURL'
| 'productDescription'
| 'appType'
| 'projectLanguageCode'
)[];
includeReviewers: boolean;
includeArtifacts: string | boolean;
start?: string;
includeArtifacts: 'apk' | 'aab' | boolean;
start?: StateName;
productId: string;
adminLevel: AdminLevel;
environment: { [key: string]: any };
productType: ProductType;
currentState?: StateName;
};

export type WorkflowInput = {
productId?: string;
adminLevel?: AdminLevel;
environment?: { [key: string]: any };
productType?: ProductType;
};

export type WorkflowStateMeta = {
level?: AdminLevel | AdminLevel[];
product?: ProductType | ProductType[];
};

export type WorkflowTransitionMeta = {
type: ActionType;
user?: RoleId;
level?: AdminLevel | AdminLevel[];
product?: ProductType | ProductType[];
};

export type WorkflowMachine = StateMachine<
WorkflowContext,
any,
any,
any,
any,
any,
any,
any,
any,
WorkflowInput,
any,
any,
WorkflowStateMeta | WorkflowTransitionMeta,
any
>;

export type StateNode = {
id: number;
label: string;
Expand All @@ -33,18 +137,41 @@ export function stateName(s: XStateNode<any, any>, machineId: string) {
}

export function targetStringFromEvent(
e: TransitionDefinition<any, AnyEventObject>[],
e: TransitionDefinition<any, AnyEventObject>,
machineId: string
): string {
return (
e[0]
e
.toJSON()
.target?.at(0)
?.replace('#' + machineId + '.', '') ?? ''
?.replace('#' + machineId + '.', '') || ''
);
}

export function filterMachine(machine: WorkflowMachine) {}

export function filterMeta(
meta: WorkflowStateMeta | WorkflowTransitionMeta | undefined,
ctx: WorkflowContext
) {
return (
meta === undefined ||
((meta.level
? Array.isArray(meta.level)
? meta.level.includes(ctx.adminLevel)
: meta.level === ctx.adminLevel
: true) &&
(meta.product
? Array.isArray(meta.product)
? meta.product.includes(ctx.productType)
: meta.product === ctx.productType
: true))
);
}

export function transform(machine: StateMachineDefinition<any, AnyEventObject>): StateNode[] {
export function transform(
machine: StateMachineDefinition<WorkflowContext, AnyEventObject>
): StateNode[] {
const id = machine.id;
const lookup = Object.keys(machine.states);
const a = Object.entries(machine.states).map(([k, v]) => {
Expand All @@ -53,16 +180,17 @@ export function transform(machine: StateMachineDefinition<any, AnyEventObject>):
label: k,
connections: Object.values(v.on).map((o) => {
return {
id: lookup.indexOf(targetStringFromEvent(o, id)),
target: targetStringFromEvent(o, id),
// treat no target on transition as self target
id: lookup.indexOf(targetStringFromEvent(o[0], id) || k),
target: targetStringFromEvent(o[0], id) || k,
label: o[0].eventType
};
}),
inCount: Object.entries(machine.states)
.map(([k, v]) => {
return Object.values(v.on).map((e) => {
// treat no target on transition as self target
return { from: k, to: targetStringFromEvent(e, id) || k };
return { from: k, to: targetStringFromEvent(e[0], id) || k };
});
})
.reduce((p, c) => {
Expand Down
62 changes: 37 additions & 25 deletions source/SIL.AppBuilder.Portal/common/workflow/db.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import DatabaseWrites from '../databaseProxy/index.js';
import { prisma } from '../index.js';
import { WorkflowContext, targetStringFromEvent, stateName } from '../public/workflow.js';
import { AnyStateMachine, StateNode } from 'xstate';
import {
WorkflowContext,
targetStringFromEvent,
stateName,
WorkflowMachine,
filterMeta,
ActionType,
StateName
} from '../public/workflow.js';
import { StateNode } from 'xstate';
import { RoleId, ProductTransitionType } from '../public/prisma.js';

export type Snapshot = {
value: string;
context: WorkflowContext;
};

export async function createSnapshot(state: string, context: WorkflowContext) {
export async function createSnapshot(state: StateName, context: WorkflowContext) {
return DatabaseWrites.workflowInstances.update({
where: {
ProductId: context.productId
Expand All @@ -20,7 +28,7 @@ export async function createSnapshot(state: string, context: WorkflowContext) {
});
}

export async function getSnapshot(productId: string, machine: AnyStateMachine) {
export async function getSnapshot(productId: string, machine: WorkflowMachine) {
const snap = JSON.parse(
(
await prisma.workflowInstances.findUnique({
Expand Down Expand Up @@ -50,7 +58,7 @@ export async function getSnapshot(productId: string, machine: AnyStateMachine) {
export async function updateUserTasks(
productId: string,
roles: RoleId[],
state: string,
state: StateName,
comment?: string
) {
// Delete all tasks for a product
Expand Down Expand Up @@ -120,34 +128,38 @@ export async function updateUserTasks(
});
}

function transitionFromState(state: StateNode<any, any>, machineId: string, productId: string) {
console.log(state);
function transitionFromState(
state: StateNode<any, any>,
machineId: string,
context: WorkflowContext
) {
const t = Object.values(state.on)
.map((v) => v.filter((t) => filterMeta(t.meta, context)))
.filter((v) => v.length > 0 && filterMeta(v[0].meta, context))[0][0];
return {
ProductId: productId,
ProductId: context.productId,
TransitionType: ProductTransitionType.Activity,
InitialState: stateName(state, machineId),
DestinationState: targetStringFromEvent(Object.values(state.on)[0], machineId),
Command:
Object.keys(state.on)[0].split(':')[1] !== 'Auto'
? Object.keys(state.on)[0].split(':')[0]
: null
DestinationState: targetStringFromEvent(t, machineId),
Command: t.meta.type !== ActionType.Auto ? t.eventType : null
};
}

async function populateTransitions(machine: AnyStateMachine, productId: string) {
async function populateTransitions(machine: WorkflowMachine, context: WorkflowContext) {
return DatabaseWrites.productTransitions.createManyAndReturn({
data: [
{
ProductId: productId,
ProductId: context.productId,
DateTransition: new Date(),
TransitionType: ProductTransitionType.StartWorkflow
}
].concat(
Object.entries(machine.states).reduce(
(p, [k, v], i) =>
p.concat(
i === 1 || (i > 1 && p[p.length - 1]?.DestinationState === k && v.type !== 'final')
? [transitionFromState(v, machine.id, productId)]
filterMeta(v.meta, context) &&
(i === 1 || (i > 1 && p[p.length - 1]?.DestinationState === k && v.type !== 'final'))
? [transitionFromState(v, machine.id, context)]
: []
),
[]
Expand All @@ -163,25 +175,25 @@ async function populateTransitions(machine: AnyStateMachine, productId: string)
* Otherwise, create.
*/
export async function updateProductTransitions(
machine: AnyStateMachine,
productId: string,
machine: WorkflowMachine,
context: WorkflowContext,
userId: number | null,
initialState: string,
destinationState: string,
initialState: StateName,
destinationState: StateName,
command?: string,
comment?: string
) {
const transitions = await prisma.productTransitions.count({
where: {
ProductId: productId
ProductId: context.productId
}
});
if (transitions <= 0) {
await populateTransitions(machine, productId);
await populateTransitions(machine, context);
}
const transition = await prisma.productTransitions.findFirst({
where: {
ProductId: productId,
ProductId: context.productId,
InitialState: initialState,
DestinationState: destinationState,
DateTransition: null
Expand Down Expand Up @@ -219,7 +231,7 @@ export async function updateProductTransitions(
} else {
await DatabaseWrites.productTransitions.create({
data: {
ProductId: productId,
ProductId: context.productId,
WorkflowUserId: user?.WorkflowUserId ?? null,
AllowedUserNames: user?.Name ?? null,
InitialState: initialState,
Expand Down
Loading

0 comments on commit 4b79a9d

Please sign in to comment.