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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,13 @@ The site ships one example feature, a guestbook at `/guestbook`: a pure decision

To remove it:

1. Delete `apps/site/src/features/guestbook`.
1. Delete `apps/site/src/features/guestbook` and the guestbook store law suite at `apps/site/tests/features/guestbook`.
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 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.
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. Do the same for the guestbook store law suite's dev-only dependencies — `miniflare` and `@systemfsoftware/effect-gherkin-spec` (the `catalog:` line for miniflare, and both entries in `apps/site/package.json`'s `devDependencies`).
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.
Expand Down
5 changes: 5 additions & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"author": "TODO <todo@example.com>",
"description": "The one Worker: the starter's TanStack Start site",
"imports": {
"#guestbook/*": "./src/features/guestbook/*"
},
"dependencies": {
"@systemfsoftware/effect-cell-types": "catalog:",
"@systemfsoftware/xstate": "catalog:",
Expand All @@ -16,6 +19,7 @@
"@cloudflare/vite-plugin": "catalog:",
"@cloudflare/workers-types": "catalog:",
"@rolldown/plugin-babel": "catalog:",
"@systemfsoftware/effect-gherkin-spec": "catalog:",
"@systemfsoftware/oxlint-config-recommended": "catalog:",
"@systemfsoftware/stryker-ignorer-effect-schema-declarations": "catalog:",
"@systemfsoftware/stryker-js": "catalog:",
Expand All @@ -30,6 +34,7 @@
"@vitejs/plugin-react": "catalog:",
"alchemy": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"miniflare": "catalog:",
"oxlint": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
Expand Down
78 changes: 78 additions & 0 deletions apps/site/src/features/guestbook/guestbook-store.memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as Effect from 'effect/Effect'
import * as Layer from 'effect/Layer'
import * as Ref from 'effect/Ref'
import * as S from 'effect/Schema'

import { GuestbookStore } from './guestbook-store.service'
import {
EntryNotFound,
GuestbookEntries,
GuestbookEntry,
GuestbookRowInvalid,
StoredStateRow,
TransitionConflict,
} from './guestbook.schema'
import type { EntryAccepted } from './sign-guestbook.workflow'

const LATEST_ENTRIES = 20

export interface MemorySeedRow {
readonly id: number
readonly guest: string
readonly message: string
readonly state: string
}

interface StoredRow {
readonly id: number
readonly guest: string
readonly message: string
readonly state: string
}

const rowInvalid = (error: S.SchemaError): GuestbookRowInvalid => new GuestbookRowInvalid({ detail: error.message })

const visibleInLatest = (row: StoredRow): boolean => row.state === 'Visible' || row.state === 'Flagged'

const nextId = (rows: ReadonlyArray<StoredRow>): number => rows.reduce((max, row) => Math.max(max, row.id), 0) + 1

export const layer = (seed: ReadonlyArray<MemorySeedRow> = []): Layer.Layer<GuestbookStore> =>
Layer.effect(
GuestbookStore,
Effect.gen(function*() {
const rows = yield* Ref.make<ReadonlyArray<StoredRow>>(seed)
return {
sign: (entry: S.Codec.Encoded<typeof EntryAccepted>) =>
Ref.modify(rows, (all): readonly [StoredRow, ReadonlyArray<StoredRow>] => {
const row: StoredRow = { id: nextId(all), guest: entry.guest, message: entry.message, state: 'Visible' }
return [row, [...all, row]]
}).pipe(
Effect.flatMap((row) => S.decodeUnknownEffect(GuestbookEntry)(row).pipe(Effect.mapError(rowInvalid))),
),
latest: Ref.get(rows).pipe(
Effect.flatMap((all) =>
S.decodeUnknownEffect(GuestbookEntries)(
all.filter(visibleInLatest).sort((a, b) => b.id - a.id).slice(0, LATEST_ENTRIES),
).pipe(Effect.mapError(rowInvalid))
),
),
stateOf: (id) =>
Ref.get(rows).pipe(
Effect.map((all) => all.find((row) => row.id === id)),
Effect.flatMap((row) => Effect.mapError(Effect.fromNullishOr(row), () => new EntryNotFound({ id }))),
Effect.flatMap((row) => S.decodeEffect(StoredStateRow)(row).pipe(Effect.mapError(rowInvalid))),
Effect.map(({ state }) => state),
),
move: (id, from, to) =>
Ref.modify(
rows,
(all): readonly [Effect.Effect<typeof to, TransitionConflict>, ReadonlyArray<StoredRow>] => {
const holdsFrom = all.some((stored) => (stored.id === id ? stored.state === from : false))
return holdsFrom
? [Effect.succeed(to), all.map((stored) => (stored.id === id ? { ...stored, state: to } : stored))]
: [Effect.fail(new TransitionConflict({ id, from })), all]
},
).pipe(Effect.flatten),
}
}),
)
74 changes: 74 additions & 0 deletions apps/site/tests/features/guestbook/__fixtures__/d1-oracle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { readdir, readFile } from 'node:fs/promises'

import * as Context from 'effect/Context'
import * as Effect from 'effect/Effect'
import * as Layer from 'effect/Layer'
import { convertV4MiniflareOptions, Miniflare } from 'miniflare'

import { layer as d1Layer } from '#guestbook/d1-guestbook-store.ts'
import type { MemorySeedRow } from '#guestbook/guestbook-store.memory.ts'
import { GuestbookStore } from '#guestbook/guestbook-store.service.ts'

const migrationsUrl = new URL('../../../../src/features/guestbook/migrations/', import.meta.url)

const singleLineStatements = (sql: string): ReadonlyArray<string> =>
sql.split(';').map((statement) => statement.replace(/\s+/g, ' ').trim()).filter((statement) => statement.length > 0)

export class GuestbookMigrations extends Context.Service<GuestbookMigrations, ReadonlyArray<string>>()(
'test/GuestbookMigrations',
) {}

export const migrationsLayer: Layer.Layer<GuestbookMigrations> = Layer.effect(
GuestbookMigrations,
Effect.gen(function*() {
const names = yield* Effect.tryPromise(() => readdir(migrationsUrl)).pipe(Effect.orDie)
const ordered = names.filter((name) => name.endsWith('.sql')).sort()
const byFile = yield* Effect.forEach(
ordered,
(name) =>
Effect.tryPromise(() => readFile(new URL(name, migrationsUrl), 'utf8')).pipe(
Effect.orDie,
Effect.map(singleLineStatements),
),
)
return byFile.flat()
}),
)

export const layer = (
seed: ReadonlyArray<MemorySeedRow> = [],
): Layer.Layer<GuestbookStore, never, GuestbookMigrations> =>
Layer.effect(
GuestbookStore,
Effect.gen(function*() {
const miniflare = yield* Effect.acquireRelease(
Effect.sync(
() =>
new Miniflare(
convertV4MiniflareOptions({
script: 'export default { fetch: () => new Response(null, { status: 404 }) }',
modules: true,
compatibilityDate: '2026-10-01',
d1Databases: ['GUESTBOOK'],
}),
),
),
(instance) => Effect.promise(() => instance.dispose()),
)
const database = yield* Effect.tryPromise(() => miniflare.getD1Database('GUESTBOOK')).pipe(Effect.orDie)
const migrations = yield* GuestbookMigrations
for (const statement of migrations) {
yield* Effect.tryPromise(() => database.exec(statement)).pipe(Effect.orDie)
}
for (const row of seed) {
yield* Effect.tryPromise(() =>
database
.prepare('INSERT INTO guestbook_entries (id, guest, message, state) VALUES (?1, ?2, ?3, ?4)')
.bind(row.id, row.guest, row.message, row.state)
.run()
).pipe(Effect.orDie)
}
const built = yield* Layer.build(d1Layer(database))
return Context.get(built, GuestbookStore)
}),
)
Loading
Loading