diff --git a/README.md b/README.md index 7d889c2..b311150 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/apps/site-e2e/tests/features/guestbook/__fixtures__/guestbook.fixture.ts b/apps/site-e2e/tests/features/guestbook/__fixtures__/guestbook.fixture.ts index ff29415..b21219f 100644 --- a/apps/site-e2e/tests/features/guestbook/__fixtures__/guestbook.fixture.ts +++ b/apps/site-e2e/tests/features/guestbook/__fixtures__/guestbook.fixture.ts @@ -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> => + 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 => { + 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`)) @@ -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 = (use: (page: Page, origin: string) => Effect.Effect): Effect.Effect => +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 = ( + use: (page: Page, origin: string) => Effect.Effect, +): Effect.Effect => Effect.gen(function*() { const origin = yield* siteUrl const browser = yield* Chromium @@ -62,6 +100,44 @@ export const listedOnPage: Effect.Effect, 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 => + inFreshContext((page, origin) => moderate(page, origin, moderation)) + +const opened = async (page: Page, origin: string): Promise => { + 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, 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 => + 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)}`))) diff --git a/apps/site-e2e/tests/features/guestbook/guestbook-moderation.integration.test.ts b/apps/site-e2e/tests/features/guestbook/guestbook-moderation.integration.test.ts new file mode 100644 index 0000000..e07a7d9 --- /dev/null +++ b/apps/site-e2e/tests/features/guestbook/guestbook-moderation.integration.test.ts @@ -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([]) + ), + ), + ) + }) diff --git a/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts b/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts index c16be97..d5062c7 100644 --- a/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts +++ b/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts @@ -71,7 +71,7 @@ const takes = ([from, on, to]: Move) => (events: ReadonlyArray): 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( diff --git a/apps/site/src/features/guestbook/guestbook-handlers.ts b/apps/site/src/features/guestbook/guestbook-handlers.ts index a8eb961..2188f4f 100644 --- a/apps/site/src/features/guestbook/guestbook-handlers.ts +++ b/apps/site/src/features/guestbook/guestbook-handlers.ts @@ -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 @@ -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)) diff --git a/apps/site/src/features/guestbook/guestbook-page.tsx b/apps/site/src/features/guestbook/guestbook-page.tsx index 4a01ae5..fcb3538 100644 --- a/apps/site/src/features/guestbook/guestbook-page.tsx +++ b/apps/site/src/features/guestbook/guestbook-page.tsx @@ -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 { @@ -35,9 +35,23 @@ const signEntry = (command: SignGuestbook): Effect.Effect => Effect.provide(SiteClientProtocol), ) +const moderateEntry = (request: ModerationRequest): Effect.Effect => + 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('') @@ -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 (

Guestbook

@@ -70,13 +94,22 @@ export function GuestbookPage() {