Skip to content

Commit

Permalink
Merge pull request #91 from Actyx/rku/machine-event-parse
Browse files Browse the repository at this point in the history
add MachineEvent.Factory.parse() method
  • Loading branch information
rkuhn authored Jan 25, 2024
2 parents 7c34a27 + 914ef21 commit 3f9b5b4
Show file tree
Hide file tree
Showing 10 changed files with 254 additions and 18 deletions.
6 changes: 6 additions & 0 deletions machine-runner/cjs/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */

export const importZod = () => ({
zod: require('zod') as typeof import('zod'),
zodError: require('zod-validation-error') as typeof import('zod-validation-error'),
})
18 changes: 18 additions & 0 deletions machine-runner/esm/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-var-requires */

const ZOD = {
zod: (await import('zod').catch(
(e: any) => new Error(`cannot import zod, please install: ${e}`),
)) as typeof import('zod') | Error,
zodError: (await import('zod-validation-error').catch(
(e: any) => new Error(`cannot import zod, please install: ${e}`),
)) as typeof import('zod-validation-error') | Error,
}

export const importZod = () => {
const { zod, zodError } = ZOD
if (zod instanceof Error) throw zod
if (zodError instanceof Error) throw zodError
return { zod, zodError }
}
22 changes: 21 additions & 1 deletion machine-runner/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions machine-runner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@
],
"scripts": {
"build": "npm run build:esm && npm run build:cjs && npm run build:copy-package-json",
"build:esm": "tsc --build tsconfig.esm.json",
"build:cjs": "tsc --build tsconfig.cjs.json",
"copy:esm": "ts-node-esm scripts/build/copy-cjs-esm.ts esm",
"build:esm": "npm run copy:esm && tsc --build tsconfig.esm.json",
"copy:cjs": "ts-node-esm scripts/build/copy-cjs-esm.ts cjs",
"build:cjs": "npm run copy:cjs && tsc --build tsconfig.cjs.json",
"build:copy-package-json": "ts-node-esm scripts/build/copy-package-json.ts",
"clean": "npm run clean:lib && npm run test:cjs:clean",
"clean:lib": "rimraf ./lib",
"test": "npm run test:esm && npm run test:cjs",
"test:esm": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config=tests/esm/jest.config.ts",
"test:cjs": "npm run test:cjs:clean && npm run test:cjs:copy && jest --config=tests/cjs/jest.config.ts",
"test:esm": "npm run build:esm && cross-env NODE_OPTIONS=--experimental-vm-modules jest --config=tests/esm/jest.config.ts",
"test:cjs": "npm run build:cjs && npm run test:cjs:clean && npm run test:cjs:copy && jest --config=tests/cjs/jest.config.ts",
"test:cjs:copy": "ts-node-esm scripts/test/cjs-copy.ts",
"test:cjs:clean": "ts-node-esm scripts/test/cjs-remove.ts",
"lint": "npx eslint src/**/*.ts",
Expand Down Expand Up @@ -71,6 +73,7 @@
"typescript": "^4.9.5"
},
"optionalDependencies": {
"zod": "^3.21.4"
"zod": "^3.21.4",
"zod-validation-error": "^3.0.0"
}
}
31 changes: 31 additions & 0 deletions machine-runner/scripts/build/copy-cjs-esm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as fs from 'fs'

const copy = (fromDir: string, intoDir: string) => {
const from = fs.readdirSync(fromDir)
for (const entry of from) {
const stat = fs.statSync(`${fromDir}/${entry}`)
if (stat.isFile()) {
fs.copyFileSync(`${fromDir}/${entry}`, `${intoDir}/${entry}`)
} else if (stat.isDirectory()) {
fs.mkdirSync(`${intoDir}/${entry}`, { recursive: true })
copy(`${fromDir}/${entry}`, `${intoDir}/${entry}`)
}
}
}

if (process.argv.length != 3) {
console.error('Usage: node copy-cjs-esm <cjs|esm>')
process.exit(1)
}

switch (process.argv[2]) {
case 'cjs':
copy('cjs', 'src')
break
case 'esm':
copy('esm', 'src')
break
default:
console.error('Usage: node copy-cjs-esm <cjs|esm>')
process.exit(1)
}
90 changes: 80 additions & 10 deletions machine-runner/src/design/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { ActyxEvent } from '@actyx/sdk'
import * as utils from '../utils/type-utils.js'
import type * as z from 'zod'
import { importZod } from '../zod.js'

// Note on "Loose" aliases
//
Expand Down Expand Up @@ -31,11 +32,65 @@ export type MachineEvent<Key extends string, Payload extends object> = {
type: Key
} & Payload

type Zod = {
z: typeof import('zod').z
fromZodError: typeof import('zod-validation-error').fromZodError
}
const getZod = (): Zod => {
const z = importZod()
return {
z: z.zod.z,
fromZodError: z.zodError.fromZodError,
}
}

/**
* Collection of utilities surrounding MachineEvent creations.
* @see MachineEvent.design for more information about designing MachineEvent
*/
export namespace MachineEvent {
const mkParse = <Key extends string, Payload extends object>(
key: Key,
zodDefinition?: z.ZodType<Payload>,
) => {
const defaultParser: (event: MachineEvent<Key, Payload>) => ParseResult<Payload> = (event) => {
if (typeof event !== 'object' || event === null) {
return { success: false, error: `Event ${event} is not an object` }
}

if (event.type !== key) {
return {
success: false,
error: `Event type ${event.type} does not match expected type ${key}`,
}
}

return { success: true, event }
}

if (!zodDefinition) {
return defaultParser
} else {
const [zod, fromZodError] = (() => {
const { z, fromZodError } = getZod()
const zod = z.intersection(zodDefinition, z.object({ type: z.string() }))
return [zod, fromZodError] as const
})()

return (event: MachineEvent<Key, Payload>): ParseResult<Payload> => {
const defaultParserResult = defaultParser(event)
if (!defaultParserResult.success) return defaultParserResult

const result = zod.safeParse(event)
if (!result.success) {
return { success: false, error: fromZodError(result.error).toString() }
}

return { success: true, event: result.data }
}
}
}

/**
* Start a design of a MachineEventFactory used for MachineRunner.
*
Expand Down Expand Up @@ -63,25 +118,23 @@ export namespace MachineEvent {
*/
export const design = <Key extends string>(key: Key): EventFactoryIntermediate<Key> => ({
withZod: (zodDefinition) => ({
[FactoryInternalsAccessor]: {
zodDefinition: zodDefinition,
},
[FactoryInternalsAccessor]: { zodDefinition },
type: key,
make: (payload) => ({
...zodDefinition.parse(payload),
type: key,
}),
parse: mkParse(key, zodDefinition),
}),

withPayload: () => ({
[FactoryInternalsAccessor]: {
zodDefinition: undefined,
},
[FactoryInternalsAccessor]: { zodDefinition: undefined },
type: key,
make: (payload) => ({
...payload,
type: key,
}),
parse: mkParse(key),
}),

withoutPayload: () => ({
Expand All @@ -90,6 +143,7 @@ export namespace MachineEvent {
},
type: key,
make: () => ({ type: key }),
parse: mkParse(key),
}),
})

Expand Down Expand Up @@ -130,6 +184,16 @@ export namespace MachineEvent {
zodDefinition?: z.ZodType<Payload>
}

export type ParseResult<Payload> =
| {
success: true
event: Payload
}
| {
success: false
error: string
}

/**
* MachineEvent.Factory is a type definition for a constructor type that serves
* as a blueprint for the resulting instances.
Expand All @@ -150,6 +214,15 @@ export namespace MachineEvent {
* const machineEventInstance = HangarDoorTransitioning.make({ fractionOpen: 0.5 })
*/
make: (payload: Payload) => MachineEvent<Key, Payload>
/**
* This method doesn't do much unless `withZod` was used to define the state!
*
* If a zod definition was used to define the state, this method will check
* whether the given event matches this definition. If no zod definition was
* used, this method will only check whether the given event has the correct
* type field.
*/
parse: (event: MachineEvent<Key, Payload>) => ParseResult<Payload>
/**
* Contains Zod definition. Also serves to differentiate Event from
* Event.Factory when evaluated with Payload.Of
Expand All @@ -161,10 +234,7 @@ export namespace MachineEvent {
* A collection of type utilities around the Payload of a MachineEvent.Factory.
*/
export namespace Payload {
export type Of<T extends MachineEvent.Any | Factory.Any> = T extends Factory<
string,
infer Payload
>
export type Of<T extends MachineEvent.Any | Factory.Any> = T extends Factory<any, infer Payload>
? Payload
: T extends MachineEvent<any, infer Payload>
? Payload
Expand Down
6 changes: 6 additions & 0 deletions machine-runner/src/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */

export const importZod = () => ({
zod: require('zod') as typeof import('zod'),
zodError: require('zod-validation-error') as typeof import('zod-validation-error'),
})
78 changes: 78 additions & 0 deletions machine-runner/tests/esm/design.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, it } from '@jest/globals'
import { MachineEvent } from '../../lib/esm/design/event.js'
import { z } from 'zod'

describe('MachineEvent', () => {
it('should parse empty payload', () => {
const event = MachineEvent.design('a').withoutPayload()
expect(event.parse(null as any)).toEqual({
error: 'Event null is not an object',
success: false,
})
expect(event.parse({} as any)).toEqual({
error: 'Event type undefined does not match expected type a',
success: false,
})
expect(event.parse({ type: 'b' } as any)).toEqual({
error: 'Event type b does not match expected type a',
success: false,
})
expect(event.parse({ type: 'a' })).toEqual({
success: true,
event: { type: 'a' },
})
})

it('should parse non-empty non-zod payload', () => {
const event = MachineEvent.design('a').withPayload<{ a: number }>()
expect(event.parse(null as any)).toEqual({
error: 'Event null is not an object',
success: false,
})
expect(event.parse({} as any)).toEqual({
error: 'Event type undefined does not match expected type a',
success: false,
})
expect(event.parse({ type: 'b' } as any)).toEqual({
error: 'Event type b does not match expected type a',
success: false,
})
expect(event.parse({ type: 'a' } as any)).toEqual({
success: true,
event: { type: 'a' },
})
expect(event.parse({ type: 'a', a: 42 })).toEqual({
success: true,
event: { type: 'a', a: 42 },
})
})

it('should parse non-empty zod payload', () => {
const event = MachineEvent.design('a').withZod(z.object({ a: z.number() }))
expect(event.parse(null as any)).toEqual({
error: 'Event null is not an object',
success: false,
})
expect(event.parse({} as any)).toEqual({
error: 'Event type undefined does not match expected type a',
success: false,
})
expect(event.parse({ type: 'b' } as any)).toEqual({
error: 'Event type b does not match expected type a',
success: false,
})
expect(event.parse({ type: 'a' } as any)).toEqual({
success: false,
error: 'Validation error: Required at "a"',
})
expect(event.parse({ type: 'a', a: null } as any)).toEqual({
success: false,
error: 'Validation error: Expected number, received null at "a"',
})
expect(event.parse({ type: 'a', a: 42 })).toEqual({
success: true,
event: { type: 'a', a: 42 },
})
})
})
4 changes: 4 additions & 0 deletions machine-runner/tests/esm/runner-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ describe('typings', () => {
it("tags parameter from protocol should match createMachineRunner's", () => {
// Accepted parameter type
type TagsParamType = Parameters<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof createMachineRunner<any, any, typeof E1 | typeof E2, any>
>[1]

Expand All @@ -100,8 +101,11 @@ describe('typings', () => {

type ExpectedTagsType = Tags<MachineEvent.Of<typeof E1> | MachineEvent.Of<typeof E2>>

// eslint-disable-next-line @typescript-eslint/no-explicit-any
NOP<[TagsParamType]>(undefined as any as TagsArgType)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
NOP<[NotAnyOrUnknown<TagsParamType>]>(undefined as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
NOP<[NotAnyOrUnknown<TagsArgType>]>(undefined as any)
true as Expect<Equal<ExpectedTagsType, TagsParamType>>
})
Expand Down
Loading

0 comments on commit 3f9b5b4

Please sign in to comment.