Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions apps/site/src/api/check-health.cell.ts
Original file line number Diff line number Diff line change
@@ -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)),
})
2 changes: 1 addition & 1 deletion apps/site/src/api/check-health.workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()('ProbeAnswered', {}) {}

Expand Down
39 changes: 23 additions & 16 deletions apps/site/src/api/site-rpc-server.ts
Original file line number Diff line number Diff line change
@@ -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),
)
62 changes: 62 additions & 0 deletions apps/site/src/features/guestbook/d1-guestbook-store.ts
Original file line number Diff line number Diff line change
@@ -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 = <A>(run: () => Promise<A>): Effect.Effect<A, GuestbookUnavailable> =>
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<GuestbookStore> =>
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),
),
})
73 changes: 37 additions & 36 deletions apps/site/src/features/guestbook/guestbook-handlers.ts
Original file line number Diff line number Diff line change
@@ -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<SignGuestbook, GuestbookEntry, SignGuestbookRefusal | StoreFailure, never>
readonly list: Cell.Cell<void, GuestbookEntries, StoreFailure, never>
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 }),
),
})
28 changes: 28 additions & 0 deletions apps/site/src/features/guestbook/guestbook-store.service.ts
Original file line number Diff line number Diff line change
@@ -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<GuestbookStore, {
readonly sign: (entry: S.Codec.Encoded<typeof EntryAccepted>) => Effect.Effect<GuestbookEntry, StoreFailure>
readonly latest: Effect.Effect<GuestbookEntries, StoreFailure>
readonly stateOf: (id: EntryId) => Effect.Effect<string, EntryNotFound | StoreFailure>
readonly move: (
id: EntryId,
from: EntryState,
to: EntryState,
) => Effect.Effect<EntryState, TransitionConflict | GuestbookUnavailable>
}>()('site/GuestbookStore') {}
75 changes: 0 additions & 75 deletions apps/site/src/features/guestbook/guestbook-store.ts

This file was deleted.

12 changes: 12 additions & 0 deletions apps/site/src/features/guestbook/list-guestbook-entries.cell.ts
Original file line number Diff line number Diff line change
@@ -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))
18 changes: 18 additions & 0 deletions apps/site/src/features/guestbook/moderate-guestbook-entry.cell.ts
Original file line number Diff line number Diff line change
@@ -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 })),
})
Original file line number Diff line number Diff line change
Expand Up @@ -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>('ModerateGuestbookEntry')({
state: StoredEntryState,
Expand Down
Loading
Loading