diff --git a/README.md b/README.md index 1d1152c6..c02e62f2 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ To remove it: 2. Delete `apps/site-e2e/tests/features/guestbook`. 3. Delete the route file `apps/site/src/routes/guestbook.tsx`. 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. +5. `apps/site/src/api/site-rpc-server.ts`: drop the five `../features/guestbook/…` imports and the `GuestbookStoreLive` line; build the context from `SiteDatabaseLive` alone (the health check still probes D1); and delete the `guestbook` record, so the `toLayer` effect returns only `health`. 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. 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. diff --git a/apps/site/src/api/check-health.cell.ts b/apps/site/src/api/check-health.cell.ts new file mode 100644 index 00000000..00d0268a --- /dev/null +++ b/apps/site/src/api/check-health.cell.ts @@ -0,0 +1,23 @@ +import { Sandwich } from '@systemfsoftware/effect-cell-types' +import * as Effect from 'effect/Effect' + +import { SiteDatabase } from '../site-database.service' +import { checkHealth, DatabaseUnreachable, ProbeAnswered, ProbeUnanswered } from './check-health.workflow' +import type { Health } from './health.schema' + +const healthy: Health = { status: 'ok' } + +const probeDatabase = Effect.flatMap( + SiteDatabase, + (database) => Effect.tryPromise(() => database.prepare('SELECT 1').first()), +).pipe( + Effect.match({ onSuccess: () => new ProbeAnswered({}), onFailure: () => new ProbeUnanswered({}) }), +) + +export const checkHealthCell = Sandwich.named('site.health')(() => Effect.map(probeDatabase, (probe) => ({ probe }))) + .decide(checkHealth) + .write({ + Healthy: () => Effect.succeed(healthy), + DatabaseUnreachable: () => Effect.fail(new DatabaseUnreachable({})), + CommandRejected: ({ issue }) => Effect.die(new Error(issue)), + }) diff --git a/apps/site/src/api/check-health.workflow.ts b/apps/site/src/api/check-health.workflow.ts index 1e6a6a29..2163a9fe 100644 --- a/apps/site/src/api/check-health.workflow.ts +++ b/apps/site/src/api/check-health.workflow.ts @@ -3,7 +3,7 @@ import * as Match from 'effect/Match' import * as Result from 'effect/Result' import * as S from 'effect/Schema' -const CheckHealthTypeId: unique symbol = Symbol() +const CheckHealthTypeId: unique symbol = Symbol.for('site/CheckHealth') export class ProbeAnswered extends S.TaggedClass()('ProbeAnswered', {}) {} diff --git a/apps/site/src/api/site-rpc-server.ts b/apps/site/src/api/site-rpc-server.ts index ed3d4a5b..26b429a8 100644 --- a/apps/site/src/api/site-rpc-server.ts +++ b/apps/site/src/api/site-rpc-server.ts @@ -1,31 +1,38 @@ +import { Cell } from '@systemfsoftware/effect-cell-types' import { env } from 'cloudflare:workers' import * as Effect from 'effect/Effect' import { HttpServer } from 'effect/http' import * as Layer from 'effect/Layer' import { RpcSerialization, RpcServer } from 'effect/rpc' +import * as D1GuestbookStore from '../features/guestbook/d1-guestbook-store' import { GuestbookHandlers } from '../features/guestbook/guestbook-handlers' -import { CheckHealth, checkHealth, ProbeAnswered, ProbeUnanswered } from './check-health.workflow' -import type { Health } from './health.schema' -import { HealthRpcs, SITE_RPC_PATH, SiteRpcs } from './site-rpcs' +import { listGuestbookEntriesCell } from '../features/guestbook/list-guestbook-entries.cell' +import { moderateGuestbookEntryCell } from '../features/guestbook/moderate-guestbook-entry.cell' +import { signGuestbookCell } from '../features/guestbook/sign-guestbook.cell' +import { SiteDatabase } from '../site-database.service' +import { checkHealthCell } from './check-health.cell' +import { SITE_RPC_PATH, SiteRpcs } from './site-rpcs' -const healthy: Health = { status: 'ok' } +const SiteDatabaseLive = Layer.succeed(SiteDatabase, env.DB) -const probeDatabase = Effect.tryPromise(() => env.DB.prepare('SELECT 1').first()).pipe( - Effect.match({ onSuccess: () => new ProbeAnswered({}), onFailure: () => new ProbeUnanswered({}) }), -) +const GuestbookStoreLive = D1GuestbookStore.layer(env.DB) -const HealthHandlers = HealthRpcs.toLayer({ - health: () => - probeDatabase.pipe( - Effect.flatMap((probe) => Effect.fromResult(checkHealth(new CheckHealth({ probe })))), - Effect.as(healthy), - ), -}) +const SiteHandlers = SiteRpcs.toLayer( + Effect.gen(function*() { + const context = yield* Layer.build(Layer.mergeAll(SiteDatabaseLive, GuestbookStoreLive)) + const health = Cell.provideContext(checkHealthCell, context) + const guestbook = GuestbookHandlers({ + sign: Cell.provideContext(signGuestbookCell, context), + list: Cell.provideContext(listGuestbookEntriesCell, context), + moderate: Cell.provideContext(moderateGuestbookEntryCell, context), + }) + return { health: () => health.run(undefined), ...guestbook } + }), +) export const SiteRpcLive = RpcServer.layerHttp({ group: SiteRpcs, path: SITE_RPC_PATH, protocol: 'http' }).pipe( - Layer.provide(HealthHandlers), - Layer.provide(GuestbookHandlers), + Layer.provide(SiteHandlers), Layer.provide(RpcSerialization.layerJson), Layer.provide(HttpServer.layerServices), ) diff --git a/apps/site/src/features/guestbook/d1-guestbook-store.ts b/apps/site/src/features/guestbook/d1-guestbook-store.ts new file mode 100644 index 00000000..d6c2f9d0 --- /dev/null +++ b/apps/site/src/features/guestbook/d1-guestbook-store.ts @@ -0,0 +1,62 @@ +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import * as S from 'effect/Schema' + +import { GuestbookStore } from './guestbook-store.service' +import { + EntryNotFound, + GuestbookEntries, + GuestbookEntry, + GuestbookRowInvalid, + GuestbookUnavailable, + StoredStateRow, + TransitionConflict, +} from './guestbook.schema' + +const LATEST_ENTRIES = 20 + +const query = (run: () => Promise): Effect.Effect => + Effect.tryPromise({ + try: run, + catch: (cause) => + new GuestbookUnavailable({ detail: cause instanceof Error ? cause.message : 'the query was rejected' }), + }) + +const rowInvalid = (error: S.SchemaError): GuestbookRowInvalid => new GuestbookRowInvalid({ detail: error.message }) + +export const layer = (database: D1Database): Layer.Layer => + Layer.succeed(GuestbookStore, { + sign: (entry) => + query(() => + database + .prepare('INSERT INTO guestbook_entries (guest, message) VALUES (?1, ?2) RETURNING id, guest, message, state') + .bind(entry.guest, entry.message) + .first() + ).pipe(Effect.flatMap((row) => S.decodeUnknownEffect(GuestbookEntry)(row).pipe(Effect.mapError(rowInvalid)))), + latest: query(() => + database + .prepare( + "SELECT id, guest, message, state FROM guestbook_entries WHERE state IN ('Visible', 'Flagged') ORDER BY id DESC LIMIT ?1", + ) + .bind(LATEST_ENTRIES) + .all() + ).pipe( + Effect.flatMap(({ results }) => + S.decodeUnknownEffect(GuestbookEntries)(results).pipe(Effect.mapError(rowInvalid)) + ), + ), + stateOf: (id) => + query(() => database.prepare('SELECT state FROM guestbook_entries WHERE id = ?1').bind(id).first()).pipe( + Effect.flatMap((row) => Effect.mapError(Effect.fromNullishOr(row), () => new EntryNotFound({ id }))), + Effect.flatMap((row) => S.decodeUnknownEffect(StoredStateRow)(row).pipe(Effect.mapError(rowInvalid))), + Effect.map(({ state }) => state), + ), + move: (id, from, to) => + query(() => + database.prepare('UPDATE guestbook_entries SET state = ?1 WHERE id = ?2 AND state = ?3').bind(to, id, from) + .run() + ).pipe( + Effect.filterOrFail(({ meta }) => meta.changes === 1, () => new TransitionConflict({ id, from })), + Effect.as(to), + ), + }) diff --git a/apps/site/src/features/guestbook/guestbook-handlers.ts b/apps/site/src/features/guestbook/guestbook-handlers.ts index 2188f4f4..38b7f384 100644 --- a/apps/site/src/features/guestbook/guestbook-handlers.ts +++ b/apps/site/src/features/guestbook/guestbook-handlers.ts @@ -1,40 +1,41 @@ -import { Sandwich } from '@systemfsoftware/effect-cell-types' +import type { Cell } 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' +import type { + EntryNotFound, + EntryState, + GuestbookEntries, + GuestbookEntry, + GuestbookRowInvalid, + GuestbookUnavailable, + ModerationRequest, + StoredStateInvalid, + TransitionConflict, +} from './guestbook.schema' +import type { IllegalTransition } from './moderate-guestbook-entry.workflow' +import type { SignGuestbook, SignGuestbookRefusal } 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 })), - }) +type StoreFailure = GuestbookUnavailable | GuestbookRowInvalid -export const GuestbookHandlers = GuestbookRpcs.toLayer( - Effect.gen(function*() { - const store = yield* GuestbookStore - return { - sign: (command) => - signGuestbook(command).pipe( - Effect.fromResult, - 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)) +export interface GuestbookCells { + readonly sign: Cell.Cell + readonly list: Cell.Cell + readonly moderate: Cell.Cell< + ModerationRequest, + EntryState, + EntryNotFound | TransitionConflict | IllegalTransition | StoredStateInvalid | StoreFailure, + never + > +} + +export const GuestbookHandlers = (cells: GuestbookCells) => ({ + sign: (command: SignGuestbook) => + cells.sign.run(command).pipe( + Effect.catchTags({ GuestbookUnavailable: Effect.die, GuestbookRowInvalid: Effect.die }), + ), + list: () => cells.list.run(undefined).pipe(Effect.orDie), + moderate: (request: ModerationRequest) => + cells.moderate.run(request).pipe( + Effect.catchTags({ GuestbookUnavailable: Effect.die, GuestbookRowInvalid: Effect.die }), + ), +}) diff --git a/apps/site/src/features/guestbook/guestbook-store.service.ts b/apps/site/src/features/guestbook/guestbook-store.service.ts new file mode 100644 index 00000000..8bad25c4 --- /dev/null +++ b/apps/site/src/features/guestbook/guestbook-store.service.ts @@ -0,0 +1,28 @@ +import * as Context from 'effect/Context' +import type * as Effect from 'effect/Effect' +import type * as S from 'effect/Schema' + +import type { + EntryId, + EntryNotFound, + EntryState, + GuestbookEntries, + GuestbookEntry, + GuestbookRowInvalid, + GuestbookUnavailable, + TransitionConflict, +} from './guestbook.schema' +import type { EntryAccepted } from './sign-guestbook.workflow' + +type StoreFailure = GuestbookUnavailable | GuestbookRowInvalid + +export class GuestbookStore extends Context.Service) => Effect.Effect + readonly latest: Effect.Effect + readonly stateOf: (id: EntryId) => Effect.Effect + readonly move: ( + id: EntryId, + from: EntryState, + to: EntryState, + ) => Effect.Effect +}>()('site/GuestbookStore') {} diff --git a/apps/site/src/features/guestbook/guestbook-store.ts b/apps/site/src/features/guestbook/guestbook-store.ts deleted file mode 100644 index 02e35c51..00000000 --- a/apps/site/src/features/guestbook/guestbook-store.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { env } from 'cloudflare:workers' -import * as Context from 'effect/Context' -import * as Effect from 'effect/Effect' -import * as Layer from 'effect/Layer' -import * as S from 'effect/Schema' - -import { - type EntryId, - EntryNotFound, - type EntryState, - GuestbookEntries, - GuestbookEntry, - GuestbookRowInvalid, - GuestbookUnavailable, - StoredStateRow, - TransitionConflict, -} from './guestbook.schema' -import type { EntryAccepted } from './sign-guestbook.workflow' - -const LATEST_ENTRIES = 20 - -type StoreFailure = GuestbookUnavailable | GuestbookRowInvalid - -export class GuestbookStore extends Context.Service Effect.Effect - readonly latest: Effect.Effect - readonly stateOf: (id: EntryId) => Effect.Effect - readonly move: ( - id: EntryId, - from: EntryState, - to: EntryState, - ) => Effect.Effect -}>()('site/GuestbookStore') {} - -const query = (run: () => Promise): Effect.Effect => - Effect.tryPromise({ - try: run, - catch: (cause) => - new GuestbookUnavailable({ detail: cause instanceof Error ? cause.message : 'the query was rejected' }), - }) - -const rowInvalid = (error: S.SchemaError): GuestbookRowInvalid => new GuestbookRowInvalid({ detail: error.message }) - -export const GuestbookStoreLive = Layer.succeed(GuestbookStore, { - sign: (entry) => - query(() => - env.DB.prepare( - 'INSERT INTO guestbook_entries (guest, message) VALUES (?1, ?2) RETURNING id, guest, message, state', - ) - .bind(entry.guest, entry.message) - .first() - ).pipe(Effect.flatMap((row) => S.decodeUnknownEffect(GuestbookEntry)(row).pipe(Effect.mapError(rowInvalid)))), - latest: query(() => - env.DB.prepare( - "SELECT id, guest, message, state FROM guestbook_entries WHERE state IN ('Visible', 'Flagged') ORDER BY id DESC LIMIT ?1", - ) - .bind(LATEST_ENTRIES) - .all() - ).pipe( - Effect.flatMap(({ results }) => S.decodeUnknownEffect(GuestbookEntries)(results).pipe(Effect.mapError(rowInvalid))), - ), - stateOf: (id) => - query(() => env.DB.prepare('SELECT state FROM guestbook_entries WHERE id = ?1').bind(id).first()).pipe( - Effect.flatMap((row) => Effect.mapError(Effect.fromNullishOr(row), () => new EntryNotFound({ id }))), - Effect.flatMap((row) => S.decodeUnknownEffect(StoredStateRow)(row).pipe(Effect.mapError(rowInvalid))), - Effect.map(({ state }) => state), - ), - move: (id, from, to) => - query(() => - env.DB.prepare('UPDATE guestbook_entries SET state = ?1 WHERE id = ?2 AND state = ?3').bind(to, id, from).run() - ).pipe( - Effect.filterOrFail(({ meta }) => meta.changes === 1, () => new TransitionConflict({ id, from })), - Effect.as(to), - ), -}) diff --git a/apps/site/src/features/guestbook/list-guestbook-entries.cell.ts b/apps/site/src/features/guestbook/list-guestbook-entries.cell.ts new file mode 100644 index 00000000..2921fd52 --- /dev/null +++ b/apps/site/src/features/guestbook/list-guestbook-entries.cell.ts @@ -0,0 +1,12 @@ +import { Cell } from '@systemfsoftware/effect-cell-types' +import * as Effect from 'effect/Effect' + +import { GuestbookStore } from './guestbook-store.service' +import type { GuestbookEntries, GuestbookRowInvalid, GuestbookUnavailable } from './guestbook.schema' + +export const listGuestbookEntriesCell = Cell.fromEffect< + GuestbookEntries, + GuestbookUnavailable | GuestbookRowInvalid, + GuestbookStore, + void +>(Effect.flatMap(GuestbookStore, (store) => store.latest)) diff --git a/apps/site/src/features/guestbook/moderate-guestbook-entry.cell.ts b/apps/site/src/features/guestbook/moderate-guestbook-entry.cell.ts new file mode 100644 index 00000000..489056c4 --- /dev/null +++ b/apps/site/src/features/guestbook/moderate-guestbook-entry.cell.ts @@ -0,0 +1,18 @@ +import { Sandwich } from '@systemfsoftware/effect-cell-types' +import * as Effect from 'effect/Effect' + +import { GuestbookStore } from './guestbook-store.service' +import { type ModerationRequest, StoredStateInvalid } from './guestbook.schema' +import { IllegalTransition, moderateGuestbookEntry } from './moderate-guestbook-entry.workflow' + +export const moderateGuestbookEntryCell = 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 })), + }) diff --git a/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts b/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts index 3d3081b7..cc2d47e2 100644 --- a/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts +++ b/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts @@ -6,7 +6,7 @@ import * as S from 'effect/Schema' import { EntryState, LifecycleEvent, StoredEntryState } from './guestbook.schema.ts' -const ModerateGuestbookEntryTypeId: unique symbol = Symbol() +const ModerateGuestbookEntryTypeId: unique symbol = Symbol.for('site/ModerateGuestbookEntry') export class ModerateGuestbookEntry extends S.Class('ModerateGuestbookEntry')({ state: StoredEntryState, diff --git a/apps/site/src/features/guestbook/sign-guestbook.cell.ts b/apps/site/src/features/guestbook/sign-guestbook.cell.ts new file mode 100644 index 00000000..52ffcfb5 --- /dev/null +++ b/apps/site/src/features/guestbook/sign-guestbook.cell.ts @@ -0,0 +1,25 @@ +import { Sandwich } from '@systemfsoftware/effect-cell-types' +import * as Effect from 'effect/Effect' + +import { GuestbookStore } from './guestbook-store.service' +import { + MessageMissing, + MessageTooLong, + NameMissing, + NameTooLong, + type SignGuestbook, + signGuestbook, +} from './sign-guestbook.workflow' + +export const signGuestbookCell = Sandwich.named('guestbook.sign')((command: SignGuestbook) => + Effect.succeed({ name: command.name, message: command.message }) +) + .decide(signGuestbook) + .write({ + EntryAccepted: (entry) => Effect.flatMap(GuestbookStore, (store) => store.sign(entry)), + NameMissing: () => Effect.fail(new NameMissing({})), + NameTooLong: () => Effect.fail(new NameTooLong({})), + MessageMissing: () => Effect.fail(new MessageMissing({})), + MessageTooLong: () => Effect.fail(new MessageTooLong({})), + CommandRejected: ({ issue }) => Effect.die(new Error(issue)), + }) diff --git a/apps/site/src/features/guestbook/sign-guestbook.workflow.ts b/apps/site/src/features/guestbook/sign-guestbook.workflow.ts index 3669b0ef..4f4ce635 100644 --- a/apps/site/src/features/guestbook/sign-guestbook.workflow.ts +++ b/apps/site/src/features/guestbook/sign-guestbook.workflow.ts @@ -6,7 +6,7 @@ import * as Str from 'effect/String' import { GuestMessage, GuestName } from './guestbook.schema.ts' -const SignGuestbookTypeId: unique symbol = Symbol() +const SignGuestbookTypeId: unique symbol = Symbol.for('site/SignGuestbook') export class SignGuestbook extends S.Class('SignGuestbook')({ name: S.String, diff --git a/apps/site/src/site-database.service.ts b/apps/site/src/site-database.service.ts new file mode 100644 index 00000000..423008ab --- /dev/null +++ b/apps/site/src/site-database.service.ts @@ -0,0 +1,3 @@ +import * as Context from 'effect/Context' + +export class SiteDatabase extends Context.Service()('site/SiteDatabase') {}