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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ pnpm dev # the whole app at http://localhost:1337, Alchemy's local emulati
pnpm journeys # the end-to-end journeys in a real browser against pnpm dev
```

The site ships one example feature, a guestbook at `/guestbook`: a pure decision (`sign-guestbook.workflow.ts`, built with `Workflow.make`) trims a name and a message and refuses them with typed errors; the RPC procedures `sign` and `list` write and read entries in D1, with the decision's tagged refusals as `sign`'s error schema; and the page shows the entries or the refusal. Everything it owns lives in [`apps/site/src/features/guestbook`](apps/site/src/features/guestbook) and [`apps/site-e2e/tests/features/guestbook`](apps/site-e2e/tests/features/guestbook).
The site ships one example feature, a guestbook at `/guestbook`: a pure decision (`sign-guestbook.workflow.ts`, built with `Workflow.make`) trims a name and a message and refuses them with typed errors; the RPC procedures `sign` and `list` write and read entries in D1, with the decision's tagged refusals as `sign`'s error schema; and the page shows the entries or the refusal. Any visitor can flag or vouch for an entry: a second decision (`moderate-guestbook-entry.workflow.ts`) applies the event to the entry's stored state (`Visible`, `Flagged` or `Hidden`) with the pure `transition` of a machine from our XState fork, and refuses an event the state can't take. The `moderate` procedure writes the new state only if the row still holds the state it read, and `list` never returns a `Hidden` entry. Everything it owns lives in [`apps/site/src/features/guestbook`](apps/site/src/features/guestbook) and [`apps/site-e2e/tests/features/guestbook`](apps/site-e2e/tests/features/guestbook).

To remove it:

Expand All @@ -125,11 +125,11 @@ To remove it:
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`.
8. Run `pnpm install --no-frozen-lockfile` from the shell you already have open. It rewrites `pnpm-lock.yaml` without its xstate entries; `--frozen-lockfile` (pnpm's default when `CI` is set) refuses that rewrite. The devshell builds its pnpm store from `pnpm-lock.yaml`, so it can't start again until the lockfile drops xstate.
9. `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`.
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';`.
Removal leaves a deployed D1 as it is: the `guestbook_entries` table and its `0001_create_guestbook_entries.sql` and `0002_add_guestbook_entry_state.sql` rows 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 IN ('0001_create_guestbook_entries.sql', '0002_add_guestbook_entry_state.sql');`.

### 5. Make the Gates Block Merges

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,42 @@ export interface PageVisit {
readonly notice: string
}

export interface Moderation {
readonly message: string
readonly event: 'Flag' | 'Vouch'
}

const signedOrRefused = (message: string): boolean =>
document.querySelector('[role=alert]')?.textContent !== '' ||
(message !== '' && document.querySelector('ol')?.textContent.includes(message) === true)

const submitEnabled = (): boolean => document.querySelector('button[type=submit]')?.hasAttribute('disabled') === false

const entryLines = (page: Page): Promise<ReadonlyArray<string>> =>
page.locator('ol li').evaluateAll((items) =>
items.map((item) => Array.from(item.querySelectorAll(':scope > span'), (part) => part.textContent).join(' '))
)

const rpcAnswered = (page: Page, procedure: string) =>
page.waitForResponse((response) =>
response.url().endsWith('/api/rpc') && response.request().postData()?.includes(`"tag":"${procedure}"`) === true
)

const clickToModerate = async (page: Page, moderation: Moderation): Promise<void> => {
const answered = Promise.all([rpcAnswered(page, 'moderate'), rpcAnswered(page, 'list')])
const button = moderation.event === 'Flag' ? /^Flag / : /^Vouch for /
await page.getByRole('listitem').filter({ hasText: moderation.message }).getByRole('button', { name: button }).click()
await answered
await page.waitForFunction(submitEnabled)
}

const visitOf = (page: Page) =>
Effect.gen(function*() {
const entries = yield* Effect.promise(() => entryLines(page))
const notice = yield* Effect.promise(() => page.getByRole('alert').textContent())
return { entries, notice: notice ?? '' } satisfies PageVisit
})

const sign = (page: Page, origin: string, entry: Entry) =>
Effect.gen(function*() {
yield* Effect.promise(() => page.goto(`${origin}/guestbook`))
Expand All @@ -39,12 +69,20 @@ const sign = (page: Page, origin: string, entry: Entry) =>
yield* Effect.promise(() => page.getByLabel('Message').fill(entry.message))
yield* Effect.promise(() => page.getByRole('button', { name: 'Sign the guestbook' }).click())
yield* Effect.promise(() => page.waitForFunction(signedOrRefused, entry.message.trim()))
const entries = yield* Effect.promise(() => page.locator('ol li').allTextContents())
const notice = yield* Effect.promise(() => page.getByRole('alert').textContent())
return { entries, notice: notice ?? '' } satisfies PageVisit
return yield* visitOf(page)
})

const inFreshContext = <A>(use: (page: Page, origin: string) => Effect.Effect<A>): Effect.Effect<A, never, Chromium> =>
const moderate = (page: Page, origin: string, moderation: Moderation) =>
Effect.gen(function*() {
yield* Effect.promise(() => page.goto(`${origin}/guestbook`))
yield* Effect.promise(() => page.waitForFunction(submitEnabled))
yield* Effect.promise(() => clickToModerate(page, moderation))
return yield* visitOf(page)
})

const inFreshContext = <A, R = never>(
use: (page: Page, origin: string) => Effect.Effect<A, never, R>,
): Effect.Effect<A, never, Chromium | R> =>
Effect.gen(function*() {
const origin = yield* siteUrl
const browser = yield* Chromium
Expand All @@ -62,6 +100,44 @@ export const listedOnPage: Effect.Effect<ReadonlyArray<string>, never, Chromium>
Effect.gen(function*() {
yield* Effect.promise(() => page.goto(`${origin}/guestbook`))
yield* Effect.promise(() => page.waitForFunction(submitEnabled))
return yield* Effect.promise(() => page.locator('ol li').allTextContents())
return yield* Effect.promise(() => entryLines(page))
})
)

export const moderateOnPage = (moderation: Moderation): Effect.Effect<PageVisit, never, Chromium> =>
inFreshContext((page, origin) => moderate(page, origin, moderation))

const opened = async (page: Page, origin: string): Promise<void> => {
await page.goto(`${origin}/guestbook`)
await page.waitForFunction(submitEnabled)
}

const answerOf = (notice: string | null): string =>
notice === '' ? 'ok' : notice?.includes("can't take") === true
? 'illegal'
: notice?.includes('changed after it was read') === true
? 'conflict'
: `${notice}`

const stateOf = (listed: ReadonlyArray<string>, message: string): string => {
const line = listed.find((entry) => entry.includes(message))
return line === undefined ? 'Hidden' : line.endsWith(' Flagged') ? 'Flagged' : 'Visible'
}

export const raceOnPage = (message: string): Effect.Effect<string, never, Chromium> =>
inFreshContext((vouchPage, origin) =>
inFreshContext((flagPage) =>
Effect.promise(async () => {
await Promise.all([opened(vouchPage, origin), opened(flagPage, origin)])
await Promise.all([
clickToModerate(vouchPage, { message, event: 'Vouch' }),
clickToModerate(flagPage, { message, event: 'Flag' }),
])
const [vouch, flag] = await Promise.all([
vouchPage.getByRole('alert').textContent(),
flagPage.getByRole('alert').textContent(),
])
return `${answerOf(vouch)}/${answerOf(flag)}`
})
)
).pipe(Effect.flatMap((answers) => Effect.map(listedOnPage, (listed) => `${answers}/${stateOf(listed, message)}`)))
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec'
import { Effect } from 'effect'

import { ChromiumLive } from '../../__fixtures__/browser.fixture'
import { listedOnPage, moderateOnPage, raceOnPage, signOnPage, uniqueMessage } from './__fixtures__/guestbook.fixture'

const Feature = makeFeature({ it })

const CHROMIUM_TIMEOUT_MS = 180_000

const RACES = 6

const SERIAL_OUTCOMES = ['ok/conflict/Visible', 'conflict/ok/Hidden', 'illegal/ok/Hidden', 'ok/ok/Flagged']

const signed = (name: string, suffix: string) =>
Effect.flatMap(uniqueMessage, (base) => {
const message = `${base} ${suffix}`
return Effect.as(signOnPage({ name, message }), message)
})

const flagged = (suffix: string) =>
Effect.flatMap(signed('Ray', suffix), (message) => Effect.as(moderateOnPage({ message, event: 'Flag' }), message))

Feature('Flagging and vouching for guestbook entries from the page', { timeout: CHROMIUM_TIMEOUT_MS })
.withLayer(ChromiumLive)
.live('a real Chromium drives the page the running Worker serves, over its local D1 database')
.body(({ scenario }) => {
scenario(
'Two flags with no vouch between them hide an entry; one flag leaves it listed as Flagged',
Gherkin.Do.pipe(
Given('Ada, Ben and Cy have each signed the guestbook')(
'messages',
() => Effect.all({ ada: signed('Ada', 'first'), ben: signed('Ben', 'second'), cy: signed('Cy', 'third') }),
),
When("a visitor flags Ada's entry")(
'flagged',
(s) => moderateOnPage({ message: s.messages.ada, event: 'Flag' }),
),
Then("Ada's entry is listed as Flagged")((s, expect) =>
expect(s.flagged).toEqual({ notice: '', entries: expect.arrayContaining([`Ada ${s.messages.ada} Flagged`]) })
),
When('another visitor vouches for it')(
'vouched',
(s) => moderateOnPage({ message: s.messages.ada, event: 'Vouch' }),
),
Then("Ada's entry is listed without the Flagged label")((s, expect) =>
expect(s.vouched).toEqual({ notice: '', entries: expect.arrayContaining([`Ada ${s.messages.ada}`]) })
),
When('a visitor flags it again')(
'reflagged',
(s) => moderateOnPage({ message: s.messages.ada, event: 'Flag' }),
),
Then('it is Flagged once more')((s, expect) =>
expect(s.reflagged.entries).toContain(`Ada ${s.messages.ada} Flagged`)
),
When('a visitor flags it a second time with no vouch between')(
'hidden',
(s) => moderateOnPage({ message: s.messages.ada, event: 'Flag' }),
),
When("a visitor flags Ben's entry once")(
'benFlagged',
(s) => moderateOnPage({ message: s.messages.ben, event: 'Flag' }),
),
When('another visitor opens the guestbook in their own browser')('listed', () => listedOnPage),
Then("Ada's entry is gone, Ben's is Flagged and Cy's is listed as signed")((s, expect) =>
expect({
ada: s.listed.filter((line) => line.includes(s.messages.ada)),
ben: s.listed.filter((line) => line.includes(s.messages.ben)),
cy: s.listed.filter((line) => line.includes(s.messages.cy)),
}).toEqual({ ada: [], ben: [`Ben ${s.messages.ben} Flagged`], cy: [`Cy ${s.messages.cy}`] })
),
),
)

scenario(
'A vouch for an entry nobody flagged is refused and changes nothing',
Gherkin.Do.pipe(
Given('Dee has signed the guestbook')('message', () => signed('Dee', 'unflagged')),
Given('the entry as any visitor sees it')('before', () => listedOnPage),
When("a visitor vouches for Dee's entry")(
'visit',
(s) => moderateOnPage({ message: s.message, event: 'Vouch' }),
),
Then('the page names the state and the event, and the entry is what it was')((s, expect) =>
expect({ notice: s.visit.notice, entry: s.visit.entries.filter((line) => line.includes(s.message)) }).toEqual(
{
notice: "An entry that is Visible can't take Vouch.",
entry: s.before.filter((line) => line.includes(s.message)),
},
)
),
),
)

scenario(
'A vouch and a flag sent at the same moment end as one of them happening first',
Gherkin.Do.pipe(
Given('several entries that one visitor has flagged')(
'messages',
() => Effect.forEach(Array.from({ length: RACES }, (_, race) => `race ${race}`), flagged),
),
When('for each entry, one visitor vouches while another flags it')(
'outcomes',
(s) => Effect.forEach(s.messages, raceOnPage),
),
Then('each answer pair and final state is one some serial order of the two would give')((s, expect) =>
expect(s.outcomes.filter((outcome) => !SERIAL_OUTCOMES.includes(outcome))).toEqual([])
),
),
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const takes = ([from, on, to]: Move) => (events: ReadonlyArray<LifecycleEvent>):
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)
S.decodeResult(ModerateGuestbookEntry)(input)

describe('moderateGuestbookEntry — a visitor event moves an entry along R1, or is refused', () => {
it.prop(
Expand Down
20 changes: 20 additions & 0 deletions apps/site/src/features/guestbook/guestbook-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { Sandwich } from '@systemfsoftware/effect-cell-types'
import * as Effect from 'effect/Effect'
import * as Layer from 'effect/Layer'

import { GuestbookRpcs } from './guestbook-rpcs'
import { GuestbookStore, GuestbookStoreLive } from './guestbook-store'
import { type ModerationRequest, StoredStateInvalid } from './guestbook.schema'
import { IllegalTransition, moderateGuestbookEntry } from './moderate-guestbook-entry.workflow'
import { signGuestbook } from './sign-guestbook.workflow'

const moderateEntry = Sandwich.named('guestbook.moderate')((request: ModerationRequest) =>
Effect.flatMap(GuestbookStore, (store) => store.stateOf(request.id)).pipe(
Effect.map((state) => ({ id: request.id, state, event: request.event })),
)
)
.decide(moderateGuestbookEntry)
.write({
EntryMoved: ({ from, to }, { id }) => Effect.flatMap(GuestbookStore, (store) => store.move(id, from, to)),
IllegalTransition: ({ state, event }) => Effect.fail(new IllegalTransition({ state, event })),
CommandRejected: ({ issue }) => Effect.fail(new StoredStateInvalid({ detail: issue })),
})

export const GuestbookHandlers = GuestbookRpcs.toLayer(
Effect.gen(function*() {
const store = yield* GuestbookStore
Expand All @@ -15,6 +30,11 @@ export const GuestbookHandlers = GuestbookRpcs.toLayer(
Effect.flatMap((entry) => Effect.orDie(store.sign(entry))),
),
list: () => Effect.orDie(store.latest),
moderate: (request) =>
moderateEntry.run(request).pipe(
Effect.catchTags({ GuestbookUnavailable: Effect.die, GuestbookRowInvalid: Effect.die }),
Effect.provideService(GuestbookStore, store),
),
}
}),
).pipe(Layer.provide(GuestbookStoreLive))
39 changes: 36 additions & 3 deletions apps/site/src/features/guestbook/guestbook-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Effect from 'effect/Effect'
import { type SubmitEvent, useEffect, useState } from 'react'

import { siteClient, SiteClientProtocol } from '../../api/site-rpc-client'
import type { GuestbookEntries } from './guestbook.schema'
import type { GuestbookEntries, GuestbookEntry, LifecycleEvent, ModerationRequest } from './guestbook.schema'
import { SignGuestbook } from './sign-guestbook.workflow'

interface GuestbookView {
Expand Down Expand Up @@ -35,9 +35,23 @@ const signEntry = (command: SignGuestbook): Effect.Effect<string> =>
Effect.provide(SiteClientProtocol),
)

const moderateEntry = (request: ModerationRequest): Effect.Effect<string> =>
Effect.scoped(Effect.flatMap(siteClient, (client) => client.moderate(request))).pipe(
Effect.as(''),
Effect.catchTags({
IllegalTransition: (refusal) => Effect.succeed(refusal.message),
StoredStateInvalid: (refusal) => Effect.succeed(refusal.message),
TransitionConflict: (refusal) => Effect.succeed(refusal.message),
EntryNotFound: (refusal) => Effect.succeed(refusal.message),
}),
Effect.catchCause(() => Effect.succeed(UNAVAILABLE)),
Effect.provide(SiteClientProtocol),
)

export function GuestbookPage() {
const [view, setView] = useState(loading)
const [ready, setReady] = useState(false)
const [busy, setBusy] = useState(false)
const [name, setName] = useState('')
const [message, setMessage] = useState('')

Expand All @@ -60,6 +74,16 @@ export function GuestbookPage() {
)
}

const moderate = (entry: GuestbookEntry, event: LifecycleEvent) => {
setBusy(true)
void Effect.runPromise(moderateEntry({ id: entry.id, event })).then((notice) =>
Effect.runPromise(latestEntries).then((loaded) => {
setView({ entries: loaded.entries, notice: notice === '' ? loaded.notice : notice })
setBusy(false)
})
)
}

return (
<main>
<h1>Guestbook</h1>
Expand All @@ -70,13 +94,22 @@ export function GuestbookPage() {
<label>
Message <textarea name='message' value={message} onChange={(event) => setMessage(event.target.value)} />
</label>
<button type='submit' disabled={!ready}>Sign the guestbook</button>
<button type='submit' disabled={!ready || busy}>Sign the guestbook</button>
</form>
<p role='alert'>{view.notice}</p>
<ol aria-label='Entries'>
{view.entries.map((entry) => (
<li key={entry.id}>
<strong>{entry.guest}</strong> {entry.message}
<span>
<strong>{entry.guest}</strong> {entry.message}
</span>
{entry.state === 'Flagged' && <span>Flagged</span>}
<button type='button' disabled={busy} onClick={() => moderate(entry, 'Flag')}>
Flag {entry.guest}'s entry
</button>
<button type='button' disabled={busy} onClick={() => moderate(entry, 'Vouch')}>
Vouch for {entry.guest}'s entry
</button>
</li>
))}
</ol>
Expand Down
Loading
Loading