Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ To remove it:
4. `apps/site/src/api/site-rpcs.ts`: drop the `GuestbookRpcs` import and make `SiteRpcs` just `HealthRpcs`.
5. `apps/site/src/api/site-rpc-server.ts`: drop the `GuestbookHandlers` import and its `Layer.provide(GuestbookHandlers)` line.
6. `apps/site/alchemy.run.ts`: drop the `migrations` option from `Cloudflare.D1.Database('Database', …)`.
7. `pnpm-workspace.yaml`: drop the `@systemfsoftware/xstate` line under `catalog:` and its mirror under `overrides:`; `apps/site/package.json`: drop the `@systemfsoftware/xstate` dependency.
8. `flake.nix`: drop the `systemfsoftware-xstate` input, its name in the `outputs` arguments, and its two places in the `sfs-deps` derivation (the copy loop and the `jq -s add` merge); then run `nix flake lock`.
9. Run `pnpm install`.
10. Delete this README's guestbook text, from "The site ships one example feature" through the D1 note below.

Removal leaves a deployed D1 as it is: the `guestbook_entries` table and its `0001_create_guestbook_entries.sql` row in `__alchemy_migrations` stay; drop them from the D1 console in the Cloudflare dashboard with `DROP TABLE guestbook_entries; DELETE FROM __alchemy_migrations WHERE name = '0001_create_guestbook_entries.sql';`.

Expand Down
1 change: 1 addition & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"description": "The one Worker: the starter's TanStack Start site",
"dependencies": {
"@systemfsoftware/effect-cell-types": "catalog:",
"@systemfsoftware/xstate": "catalog:",
"@tanstack/react-router": "catalog:",
"@tanstack/react-start": "catalog:",
"effect": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, it } from '@systemfsoftware/vitest'
import * as Result from 'effect/Result'
import * as S from 'effect/Schema'

import { EntryState, LifecycleEvent } from '../guestbook.schema.ts'
import {
type EntryMoved,
type IllegalTransition,
ModerateGuestbookEntry,
moderateGuestbookEntry,
} from '../moderate-guestbook-entry.workflow.ts'

type Moderated = Result.Result<EntryMoved, IllegalTransition>
type Moderate = (command: ModerateGuestbookEntry) => Moderated
type Move = readonly [EntryState, LifecycleEvent, EntryState]

const STATE_NAMES: ReadonlyArray<string> = ['Visible', 'Flagged', 'Hidden']

const LEGAL_MOVES: ReadonlyArray<Move> = [
['Visible', 'Flag', 'Flagged'],
['Flagged', 'Vouch', 'Visible'],
['Flagged', 'Flag', 'Hidden'],
]

const targetOf = (state: EntryState, event: LifecycleEvent): EntryState | undefined =>
LEGAL_MOVES.find(([from, on]) => from === state && on === event)?.[2]

interface Step {
readonly before: EntryState
readonly event: LifecycleEvent
readonly outcome: Moderated
readonly after: EntryState
}

const stateAfter = (before: EntryState, outcome: Moderated): EntryState =>
Result.match(outcome, { onSuccess: (moved) => moved.to, onFailure: () => before })

const walk = (moderate: Moderate, events: ReadonlyArray<LifecycleEvent>): ReadonlyArray<Step> =>
events.reduce<ReadonlyArray<Step>>((steps, event) => {
const before = steps.at(-1)?.after ?? 'Visible'
const outcome = moderate(new ModerateGuestbookEntry({ state: before, event }))
return [...steps, { before, event, outcome, after: stateAfter(before, outcome) }]
}, [])

const refusalKeepsState = ({ before, event, outcome, after }: Step): boolean =>
Result.match(outcome, {
onSuccess: () => true,
onFailure: (refused) =>
refused.state === before && refused.event === event && after === before &&
refused.message.includes(before) && refused.message.includes(event),
})

const movesOf = (steps: ReadonlyArray<Step>): ReadonlyArray<Move> =>
steps.flatMap(({ before, event, outcome }) =>
Result.match(outcome, {
onSuccess: (moved): ReadonlyArray<Move> => [[before, event, moved.to]],
onFailure: (): ReadonlyArray<Move> => [],
})
)

const tableMovesOf = (events: ReadonlyArray<LifecycleEvent>): ReadonlyArray<Move> =>
events.reduce<{ readonly state: EntryState; readonly moves: ReadonlyArray<Move> }>(
({ state, moves }, event) => {
const to = targetOf(state, event)
return to === undefined ? { state, moves } : { state: to, moves: [...moves, [state, event, to]] }
},
{ state: 'Visible', moves: [] },
).moves

const takes = ([from, on, to]: Move) => (events: ReadonlyArray<LifecycleEvent>): boolean =>
tableMovesOf(events).some(([state, event, target]) => state === from && event === on && target === to)

const decodeCommand = (input: { readonly state: string; readonly event: LifecycleEvent }) =>
S.decodeUnknownResult(ModerateGuestbookEntry)(input)

describe('moderateGuestbookEntry — a visitor event moves an entry along R1, or is refused', () => {
it.prop(
'∀e_ReachedState_∈EntryState',
{ of: { events: S.Array(LifecycleEvent) }, subject: moderateGuestbookEntry },
(subject, { events }) => walk(subject, events).every(({ after }) => S.is(EntryState)(after)),
)

it.prop(
'∀e_RefusedEvent_=UnchangedState',
{ of: { events: S.Array(LifecycleEvent) }, subject: moderateGuestbookEntry },
(subject, { events }) => walk(subject, events).every(refusalKeepsState),
)

it.prop(
'∀e_HiddenEvent_⊥Move',
{ of: { event: LifecycleEvent }, subject: moderateGuestbookEntry },
(subject, { event }) =>
Result.match(subject(new ModerateGuestbookEntry({ state: 'Hidden', event })), {
onSuccess: () => false,
onFailure: (refused) => refused.state === 'Hidden' && refused.event === event,
}),
)

it.prop(
'∀p_LegalPair_≡HandWrittenTable',
{ of: { state: EntryState, event: LifecycleEvent }, subject: moderateGuestbookEntry },
(subject, { state, event }) =>
Result.match(subject(new ModerateGuestbookEntry({ state, event })), {
onSuccess: (moved) => moved.from === state && moved.to === targetOf(state, event),
onFailure: () => targetOf(state, event) === undefined,
}),
)

it.prop(
'∀p_MoveActions_=Empty',
{ of: { state: EntryState, event: LifecycleEvent }, subject: moderateGuestbookEntry },
(subject, { state, event }) =>
Result.match(subject(new ModerateGuestbookEntry({ state, event })), {
onSuccess: (moved) => moved.actions === 0,
onFailure: () => true,
}),
)

it.prop(
'∀e_WalkMoves_≡HandWrittenTableWalk',
{
of: [S.Array(LifecycleEvent)],
subject: moderateGuestbookEntry,
cover: {
visibleFlag: [takes(['Visible', 'Flag', 'Flagged']), 0.1],
flaggedVouch: [takes(['Flagged', 'Vouch', 'Visible']), 0.1],
flaggedFlag: [takes(['Flagged', 'Flag', 'Hidden']), 0.1],
},
},
(subject, [events]) => JSON.stringify(movesOf(walk(subject, events))) === JSON.stringify(tableMovesOf(events)),
)

it.prop(
'∀s_CommandStateDecode_≡ThreeNameMembership',
{ of: { state: S.Union([EntryState, S.String]), event: LifecycleEvent }, subject: decodeCommand },
(decode, { state, event }) => Result.isSuccess(decode({ state, event })) === STATE_NAMES.includes(state),
)
})
6 changes: 6 additions & 0 deletions apps/site/src/features/guestbook/guestbook.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export const EntryId = S.Int.pipe(
)
export type EntryId = S.Schema.Type<typeof EntryId>

export const EntryState = S.Literals(['Visible', 'Flagged', 'Hidden'])
export type EntryState = S.Schema.Type<typeof EntryState>

export const LifecycleEvent = S.Literals(['Flag', 'Vouch'])
export type LifecycleEvent = S.Schema.Type<typeof LifecycleEvent>

export const GuestbookEntry = S.Struct({ id: EntryId, guest: GuestName, message: GuestMessage })
export type GuestbookEntry = S.Schema.Type<typeof GuestbookEntry>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Workflow } from '@systemfsoftware/effect-cell-types'
import { createMachine } from '@systemfsoftware/xstate'
import * as Match from 'effect/Match'
import * as Result from 'effect/Result'
import * as S from 'effect/Schema'

import { EntryState, LifecycleEvent } from './guestbook.schema.ts'

const ModerateGuestbookEntryTypeId: unique symbol = Symbol()

export class ModerateGuestbookEntry extends S.Class<ModerateGuestbookEntry>('ModerateGuestbookEntry')({
state: EntryState,
event: LifecycleEvent,
}) {
static readonly [Workflow.InstrumentationBrand]: Record<never, never> = {}
}

export class EntryMoved extends S.TaggedClass<EntryMoved>()('EntryMoved', {
from: EntryState,
to: EntryState,
actions: S.Int,
}) {
readonly [ModerateGuestbookEntryTypeId] = ModerateGuestbookEntryTypeId
}

export class IllegalTransition extends S.TaggedError<IllegalTransition>()('IllegalTransition', {
state: EntryState,
event: LifecycleEvent,
}) {
override get message(): string {
return `An entry that is ${this.state} can't take ${this.event}.`
}
}

const lifecycle = createMachine({
initial: 'Visible',
states: {
Visible: { on: { Flag: { target: 'Flagged' } } },
Flagged: { on: { Vouch: { target: 'Visible' }, Flag: { target: 'Hidden' } } },
Hidden: {},
},
}).provide({})

export const moderateGuestbookEntry = Workflow.make({
command: ModerateGuestbookEntry,
decision: EntryMoved,
error: IllegalTransition,
decide: (command) => {
const snapshot = lifecycle.resolveState({ value: command.state })
const [next, actions] = lifecycle.transition(snapshot, { type: command.event })
return Match.value(next === snapshot).pipe(
Match.when(true, () => Result.fail(new IllegalTransition({ state: command.state, event: command.event }))),
Match.when(
false,
() => Result.succeed(new EntryMoved({ from: command.state, to: next.value, actions: actions.length })),
),
Match.exhaustive,
)
},
})
Loading
Loading