@sekiban/create-dcb 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +90 -0
- package/README.md +18 -2
- package/bin/create-dcb.mjs +91 -0
- package/package.json +17 -4
- package/template/AGENTS.md +23 -0
- package/template/LICENSE +90 -0
- package/template/README.md +76 -0
- package/template/REPLACE.md +34 -0
- package/template/cloudflare.config.ts +13 -0
- package/template/migrations/d1/g32/0001_dcb_events.sql +119 -0
- package/template/migrations/d1/g32/0002_g44_global_completeness.sql +88 -0
- package/template/migrations/d1/g32/0003_g58_safe_lane_health.sql +12 -0
- package/template/migrations/d1/g32/0004_g58_live_poll_health.sql +18 -0
- package/template/migrations/d1/g32/0005_g58_safe_lane_history.sql +18 -0
- package/template/migrations/d1/g32/0006_g60_durable_hop_measurements.sql +31 -0
- package/template/migrations/d1/g32/0007_g60_post_admission_decomposition.sql +30 -0
- package/template/migrations/d1/g32/0008_g60_unsafe_writer_boundaries.sql +24 -0
- package/template/migrations/d1/g32/0009_g65_admission_attempts.sql +23 -0
- package/template/migrations/d1/g32/0010_g65_direct_rings.sql +26 -0
- package/template/migrations/d1/g32/0011_g67_safe_lane_passes.sql +25 -0
- package/template/migrations/d1/g32/0012_g67_safe_lane_pass_ownership.sql +20 -0
- package/template/migrations/d1/g32/0013_g67_safe_lane_catch_up_observations.sql +6 -0
- package/template/migrations/d1/g32/0014_g67_safe_lane_fence_expiry.sql +6 -0
- package/template/migrations/d1/g32/0015_g69_admission_attempts.sql +33 -0
- package/template/migrations/d1/g32/0016_g69_consultation003.sql +12 -0
- package/template/migrations/d1/g32/0017_g69_receipt_identity.sql +61 -0
- package/template/migrations/d1/g32/0018_g69_mutation_evidence.sql +5 -0
- package/template/migrations/d1/g32/0019_g69_mutation_receipt_label.sql +6 -0
- package/template/migrations/d1/g32/0020_g69_hotpath_costs.sql +6 -0
- package/template/migrations/mv/0001_materialized_views.sql +87 -0
- package/template/migrations/mv/0002_unsafe_window_materialized_views.sql +58 -0
- package/template/migrations/mv/0003_checkpoint_ahead_hardening.sql +14 -0
- package/template/migrations/mv/0004_unsafe_window_failure_findings.sql +12 -0
- package/template/migrations/mv/0005_g31_wait_receipts.sql +25 -0
- package/template/migrations/mv/0006_g31_wait_target_poison.sql +48 -0
- package/template/migrations/mv/0007_g69_ordering_quarantine.sql +21 -0
- package/template/migrations/mv/0008_g69_rebuild_verification.sql +4 -0
- package/template/migrations/mv/0009_g69_rebuild_proof.sql +11 -0
- package/template/package.json +24 -0
- package/template/public/.assetsignore +2 -0
- package/template/public/app.js +40 -0
- package/template/public/index.html +58 -0
- package/template/public/styles.css +20 -0
- package/template/scripts/deploy.sh +5 -0
- package/template/scripts/migrate.sh +5 -0
- package/template/src/booking-domain.ts +285 -0
- package/template/src/booking-mv.ts +166 -0
- package/template/src/booking-routes.ts +160 -0
- package/template/src/booking-transport.ts +169 -0
- package/template/src/worker.ts +38 -0
- package/template/tsconfig.json +13 -0
- package/template/wrangler.jsonc +55 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- SDT-G69 consultation-003: an ordering detector quarantines only the
|
|
2
|
+
-- affected active generation. A rebuild/promote moves the active pointer and
|
|
3
|
+
-- resolves the old generation's quarantine without weakening frontier rules.
|
|
4
|
+
CREATE TABLE mv_ordering_quarantines (
|
|
5
|
+
service_id TEXT NOT NULL,
|
|
6
|
+
view_id TEXT NOT NULL COLLATE BINARY,
|
|
7
|
+
generation INTEGER NOT NULL,
|
|
8
|
+
checkpoint_suid TEXT NOT NULL COLLATE BINARY,
|
|
9
|
+
late_suid TEXT NOT NULL COLLATE BINARY,
|
|
10
|
+
event_id TEXT NOT NULL COLLATE BINARY,
|
|
11
|
+
classification TEXT NOT NULL CHECK (classification IN ('LATE_LOWER_SUID', 'ORDER_VIOLATION')),
|
|
12
|
+
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved')),
|
|
13
|
+
observed_at INTEGER NOT NULL,
|
|
14
|
+
resolved_at INTEGER,
|
|
15
|
+
PRIMARY KEY (service_id, view_id, generation),
|
|
16
|
+
FOREIGN KEY (service_id, view_id, generation)
|
|
17
|
+
REFERENCES mv_instances (service_id, view_id, generation)
|
|
18
|
+
ON DELETE CASCADE
|
|
19
|
+
);
|
|
20
|
+
CREATE INDEX mv_ordering_quarantines_active_idx
|
|
21
|
+
ON mv_ordering_quarantines (service_id, view_id, status, generation);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- SDT-G69 F3: promotion may resolve an ordering quarantine only after the
|
|
2
|
+
-- candidate has a durable, explicit rebuild proof. Existing generations are
|
|
3
|
+
-- intentionally unverified until a complete catch-up records the marker.
|
|
4
|
+
ALTER TABLE mv_instances ADD COLUMN rebuild_verified_at INTEGER;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- SDT-G69 F3: a rebuild proof is durable, generation-bound, and tied to the
|
|
2
|
+
-- open ordering incident it repairs. The fields are populated only by the
|
|
3
|
+
-- real MaterializedViewCatchUpRuntime rebuild path.
|
|
4
|
+
ALTER TABLE mv_instances ADD COLUMN rebuild_recovery_id TEXT;
|
|
5
|
+
ALTER TABLE mv_instances ADD COLUMN rebuild_incident_identity TEXT;
|
|
6
|
+
ALTER TABLE mv_instances ADD COLUMN rebuild_incident_generation INTEGER;
|
|
7
|
+
ALTER TABLE mv_instances ADD COLUMN rebuild_source_event_count INTEGER;
|
|
8
|
+
ALTER TABLE mv_instances ADD COLUMN rebuild_source_max_suid TEXT;
|
|
9
|
+
ALTER TABLE mv_instances ADD COLUMN rebuild_source_history_digest TEXT;
|
|
10
|
+
|
|
11
|
+
ALTER TABLE mv_ordering_quarantines ADD COLUMN incident_identity TEXT;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "A named Sekiban Cloudflare booking starter.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"migrate": "dcb-cloudflare migrate --config wrangler.jsonc",
|
|
9
|
+
"deploy": "dcb-cloudflare deploy --config wrangler.jsonc",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@sekiban/dcb-client": "0.2.0",
|
|
14
|
+
"@sekiban/dcb-core": "0.2.0",
|
|
15
|
+
"@sekiban/dcb-domain": "0.2.0",
|
|
16
|
+
"@sekiban/dcb-runtime": "0.2.0",
|
|
17
|
+
"@sekiban/dcb-cloudflare": "^0.1.0",
|
|
18
|
+
"wrangler": "4.125.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"typescript": "5.9.3",
|
|
22
|
+
"@cloudflare/workers-types": "5.20260820.1"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* global document, fetch, FormData */
|
|
2
|
+
|
|
3
|
+
const result = document.querySelector("#result");
|
|
4
|
+
|
|
5
|
+
function show(value) {
|
|
6
|
+
result.textContent = JSON.stringify(value, null, 2);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function request(path, init) {
|
|
10
|
+
const response = await fetch(path, init);
|
|
11
|
+
const body = await response.json().catch(() => ({ error: response.statusText }));
|
|
12
|
+
show(body);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const form of document.querySelectorAll("form[data-command]")) {
|
|
16
|
+
form.addEventListener("submit", async (event) => {
|
|
17
|
+
event.preventDefault();
|
|
18
|
+
const input = Object.fromEntries(new FormData(form).entries());
|
|
19
|
+
await request(`/api/commands/${form.dataset.command}`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "content-type": "application/json" },
|
|
22
|
+
body: JSON.stringify({ input }),
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const button of document.querySelectorAll("button[data-read]")) {
|
|
28
|
+
button.addEventListener("click", async () => {
|
|
29
|
+
const kind = button.dataset.read;
|
|
30
|
+
if (kind === "room") {
|
|
31
|
+
const id = document.querySelector("#room-read-id").value;
|
|
32
|
+
await request(`/api/read/room?roomId=${encodeURIComponent(id)}`);
|
|
33
|
+
} else if (kind === "reservation") {
|
|
34
|
+
const id = document.querySelector("#reservation-read-id").value;
|
|
35
|
+
await request(`/api/read/reservation?reservationId=${encodeURIComponent(id)}`);
|
|
36
|
+
} else {
|
|
37
|
+
await request("/api/read/reservations");
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{PROJECT_NAME}} booking</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main class="shell">
|
|
11
|
+
<header>
|
|
12
|
+
<p class="eyebrow">{{PROJECT_NAME}}</p>
|
|
13
|
+
<h1>Book a room</h1>
|
|
14
|
+
<p>Create a room, reserve it, inspect its state, and release it. The small demo is intentionally easy to replace.</p>
|
|
15
|
+
</header>
|
|
16
|
+
|
|
17
|
+
<section class="grid">
|
|
18
|
+
<form data-command="create-room">
|
|
19
|
+
<h2>Create room</h2>
|
|
20
|
+
<label>Room ID <input name="roomId" required placeholder="room-1"></label>
|
|
21
|
+
<label>Name <input name="name" placeholder="North studio"></label>
|
|
22
|
+
<button>Create room</button>
|
|
23
|
+
</form>
|
|
24
|
+
<form data-command="reserve-room">
|
|
25
|
+
<h2>Reserve room</h2>
|
|
26
|
+
<label>Room ID <input name="roomId" required placeholder="room-1"></label>
|
|
27
|
+
<label>Reservation ID <input name="reservationId" required placeholder="reservation-1"></label>
|
|
28
|
+
<label>User ID <input name="userId" placeholder="alex"></label>
|
|
29
|
+
<button>Reserve</button>
|
|
30
|
+
</form>
|
|
31
|
+
<form data-command="cancel-reservation">
|
|
32
|
+
<h2>Cancel reservation</h2>
|
|
33
|
+
<label>Reservation ID <input name="reservationId" required placeholder="reservation-1"></label>
|
|
34
|
+
<button>Cancel</button>
|
|
35
|
+
</form>
|
|
36
|
+
<form data-command="release-room">
|
|
37
|
+
<h2>Release room</h2>
|
|
38
|
+
<label>Room ID <input name="roomId" required placeholder="room-1"></label>
|
|
39
|
+
<button>Release</button>
|
|
40
|
+
</form>
|
|
41
|
+
</section>
|
|
42
|
+
|
|
43
|
+
<section class="reads">
|
|
44
|
+
<h2>Reads</h2>
|
|
45
|
+
<div class="read-row">
|
|
46
|
+
<input id="room-read-id" placeholder="room ID">
|
|
47
|
+
<button data-read="room">Read room</button>
|
|
48
|
+
<input id="reservation-read-id" placeholder="reservation ID">
|
|
49
|
+
<button data-read="reservation">Read reservation</button>
|
|
50
|
+
<button data-read="reservations">List reservations</button>
|
|
51
|
+
</div>
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
<pre id="result" aria-live="polite">Ready.</pre>
|
|
55
|
+
</main>
|
|
56
|
+
<script type="module" src="/app.js"></script>
|
|
57
|
+
</body>
|
|
58
|
+
</html>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
4
|
+
background: #f3f5f8;
|
|
5
|
+
color: #172033;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
body { margin: 0; }
|
|
9
|
+
.shell { max-width: 980px; margin: 0 auto; padding: 48px 20px 72px; }
|
|
10
|
+
.eyebrow { color: #5b61d6; font-size: .8rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
|
11
|
+
h1 { margin: 0 0 8px; font-size: clamp(2.2rem, 6vw, 4rem); }
|
|
12
|
+
h2 { margin-top: 0; font-size: 1.1rem; }
|
|
13
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 16px; margin: 32px 0; }
|
|
14
|
+
form, .reads { background: white; border: 1px solid #e2e6ef; border-radius: 14px; box-shadow: 0 8px 24px #1720330b; padding: 18px; }
|
|
15
|
+
label { display: grid; gap: 6px; margin: 12px 0; font-size: .9rem; }
|
|
16
|
+
input { border: 1px solid #cfd5e2; border-radius: 8px; box-sizing: border-box; padding: 10px; width: 100%; }
|
|
17
|
+
button { background: #5b61d6; border: 0; border-radius: 8px; color: white; cursor: pointer; font: inherit; padding: 10px 14px; }
|
|
18
|
+
.read-row { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; }
|
|
19
|
+
.read-row input { flex: 1 1 140px; width: auto; }
|
|
20
|
+
pre { background: #172033; border-radius: 14px; color: #e8ecf5; margin-top: 24px; min-height: 100px; overflow: auto; padding: 18px; }
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
command,
|
|
4
|
+
done,
|
|
5
|
+
domain,
|
|
6
|
+
event,
|
|
7
|
+
evolve,
|
|
8
|
+
none,
|
|
9
|
+
projector,
|
|
10
|
+
read,
|
|
11
|
+
readSet,
|
|
12
|
+
reject,
|
|
13
|
+
stateUnion,
|
|
14
|
+
tagFamily,
|
|
15
|
+
toRuntimeDomain,
|
|
16
|
+
validate,
|
|
17
|
+
validationReject,
|
|
18
|
+
type DomainViewDefinition,
|
|
19
|
+
type ProjectorDefinition,
|
|
20
|
+
type ProjectorEvent,
|
|
21
|
+
type RejectKind,
|
|
22
|
+
type RuntimeDomainDefinition,
|
|
23
|
+
} from "@sekiban/dcb-domain";
|
|
24
|
+
|
|
25
|
+
const room = tagFamily("room");
|
|
26
|
+
const reservation = tagFamily("reservation");
|
|
27
|
+
|
|
28
|
+
export const roomTag = (roomId: string) => room.of(roomId);
|
|
29
|
+
export const reservationTag = (reservationId: string) => reservation.of(reservationId);
|
|
30
|
+
|
|
31
|
+
const roomInput = z.object({ roomId: z.string().min(1), name: z.string().optional() });
|
|
32
|
+
const reservationInput = z.object({
|
|
33
|
+
roomId: z.string().min(1),
|
|
34
|
+
reservationId: z.string().min(1),
|
|
35
|
+
userId: z.string().optional(),
|
|
36
|
+
});
|
|
37
|
+
const reservationOnlyInput = z.object({ reservationId: z.string().min(1) });
|
|
38
|
+
|
|
39
|
+
export type RoomInput = z.infer<typeof roomInput>;
|
|
40
|
+
export type ReservationInput = z.infer<typeof reservationInput>;
|
|
41
|
+
export type ReservationOnlyInput = z.infer<typeof reservationOnlyInput>;
|
|
42
|
+
|
|
43
|
+
const roomCreated = event("RoomCreated", z.object({
|
|
44
|
+
roomId: z.string().min(1),
|
|
45
|
+
name: z.string(),
|
|
46
|
+
}), { tags: (payload) => [room.of(payload.roomId)] });
|
|
47
|
+
|
|
48
|
+
const roomReserved = event("RoomReserved", z.object({
|
|
49
|
+
roomId: z.string().min(1),
|
|
50
|
+
reservationId: z.string().min(1),
|
|
51
|
+
userId: z.string(),
|
|
52
|
+
}), { tags: (payload) => [room.of(payload.roomId), reservation.of(payload.reservationId)] });
|
|
53
|
+
|
|
54
|
+
const reservationCancelled = event("ReservationCancelled", z.object({
|
|
55
|
+
reservationId: z.string().min(1),
|
|
56
|
+
roomId: z.string().min(1).optional(),
|
|
57
|
+
}), { tags: (payload) => [reservation.of(payload.reservationId)] });
|
|
58
|
+
|
|
59
|
+
const roomReleased = event("RoomReleased", z.object({
|
|
60
|
+
roomId: z.string().min(1),
|
|
61
|
+
}), { tags: (payload) => [room.of(payload.roomId)] });
|
|
62
|
+
|
|
63
|
+
type RoomState =
|
|
64
|
+
| { readonly status: "empty"; readonly version: number; readonly roomId: null; readonly name: string }
|
|
65
|
+
| { readonly status: "created"; readonly version: number; readonly roomId: string; readonly name: string }
|
|
66
|
+
| { readonly status: "released"; readonly version: number; readonly roomId: string; readonly name: string };
|
|
67
|
+
|
|
68
|
+
type ReservationState =
|
|
69
|
+
| { readonly status: "empty"; readonly version: number; readonly reservationId: null; readonly roomId: null }
|
|
70
|
+
| { readonly status: "reserved"; readonly version: number; readonly reservationId: string; readonly roomId: string }
|
|
71
|
+
| { readonly status: "cancelled"; readonly version: number; readonly reservationId: string; readonly roomId: string | null };
|
|
72
|
+
|
|
73
|
+
const roomState = stateUnion(z.discriminatedUnion("status", [
|
|
74
|
+
z.object({ status: z.literal("empty"), version: z.number().int().nonnegative(), roomId: z.null(), name: z.string() }),
|
|
75
|
+
z.object({ status: z.literal("created"), version: z.number().int().nonnegative(), roomId: z.string(), name: z.string() }),
|
|
76
|
+
z.object({ status: z.literal("released"), version: z.number().int().nonnegative(), roomId: z.string(), name: z.string() }),
|
|
77
|
+
]), { initial: { status: "empty", version: 0, roomId: null, name: "" } });
|
|
78
|
+
|
|
79
|
+
const reservationState = stateUnion(z.discriminatedUnion("status", [
|
|
80
|
+
z.object({ status: z.literal("empty"), version: z.number().int().nonnegative(), reservationId: z.null(), roomId: z.null() }),
|
|
81
|
+
z.object({ status: z.literal("reserved"), version: z.number().int().nonnegative(), reservationId: z.string(), roomId: z.string() }),
|
|
82
|
+
z.object({ status: z.literal("cancelled"), version: z.number().int().nonnegative(), reservationId: z.string(), roomId: z.string().nullable() }),
|
|
83
|
+
]), { initial: { status: "empty", version: 0, reservationId: null, roomId: null } });
|
|
84
|
+
|
|
85
|
+
type RoomEvent = typeof roomCreated | typeof roomReleased;
|
|
86
|
+
type ReservationEvent = typeof roomReserved | typeof reservationCancelled;
|
|
87
|
+
|
|
88
|
+
function roomCreatedEvent(value: ProjectorEvent<RoomEvent>): ProjectorEvent<typeof roomCreated> {
|
|
89
|
+
return { definition: roomCreated, eventType: roomCreated.eventType, payload: roomCreated.make(value.payload), tags: value.tags };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function roomReleasedEvent(value: ProjectorEvent<RoomEvent>): ProjectorEvent<typeof roomReleased> {
|
|
93
|
+
return { definition: roomReleased, eventType: roomReleased.eventType, payload: roomReleased.make(value.payload), tags: value.tags };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function roomReservedEvent(value: ProjectorEvent<ReservationEvent>): ProjectorEvent<typeof roomReserved> {
|
|
97
|
+
return { definition: roomReserved, eventType: roomReserved.eventType, payload: roomReserved.make(value.payload), tags: value.tags };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function reservationCancelledEvent(value: ProjectorEvent<ReservationEvent>): ProjectorEvent<typeof reservationCancelled> {
|
|
101
|
+
return { definition: reservationCancelled, eventType: reservationCancelled.eventType, payload: reservationCancelled.make(value.payload), tags: value.tags };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const validateCreateRoom = validate<RoomState, RoomInput, RejectKind>((state) =>
|
|
105
|
+
state.status === "empty" ? undefined : validationReject("conflict", "room already exists", "room_exists"));
|
|
106
|
+
|
|
107
|
+
export const validateReserveRoom = validate<RoomState, ReservationInput, RejectKind>((state) =>
|
|
108
|
+
state.status === "empty" ? validationReject("not-found", "room does not exist", "room_missing") : undefined);
|
|
109
|
+
|
|
110
|
+
export const validateCancelReservation = validate<ReservationState, ReservationOnlyInput, RejectKind>((state) =>
|
|
111
|
+
state.status === "empty" ? validationReject("not-found", "reservation does not exist", "reservation_missing") : undefined);
|
|
112
|
+
|
|
113
|
+
export const validateReleaseRoom = validate<RoomState, RoomInput, RejectKind>((state) =>
|
|
114
|
+
state.status === "empty" ? validationReject("not-found", "room does not exist", "room_missing") : undefined);
|
|
115
|
+
|
|
116
|
+
export const evolveRoomCreated = evolve<RoomState, typeof roomCreated>((state, value) => {
|
|
117
|
+
const payload = roomCreated.parse(value.payload);
|
|
118
|
+
return { status: "created", version: state.version + 1, roomId: payload.roomId, name: payload.name };
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export const evolveRoomReleased = evolve<RoomState, typeof roomReleased>((state) => {
|
|
122
|
+
if (state.status === "empty") return state;
|
|
123
|
+
return { status: "released", version: state.version + 1, roomId: state.roomId, name: state.name };
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const evolveRoomReserved = evolve<ReservationState, typeof roomReserved>((state, value) => {
|
|
127
|
+
const payload = roomReserved.parse(value.payload);
|
|
128
|
+
return { status: "reserved", version: state.version + 1, reservationId: payload.reservationId, roomId: payload.roomId };
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export const evolveReservationCancelled = evolve<ReservationState, typeof reservationCancelled>((state) => {
|
|
132
|
+
if (state.status === "empty") return state;
|
|
133
|
+
return { status: "cancelled", version: state.version + 1, reservationId: state.reservationId, roomId: state.roomId };
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const roomProjector: ProjectorDefinition<RoomState, "room", [typeof roomCreated, typeof roomReleased]> = projector({
|
|
137
|
+
id: "RoomProjector",
|
|
138
|
+
version: 1,
|
|
139
|
+
tag: room,
|
|
140
|
+
state: roomState,
|
|
141
|
+
events: [roomCreated, roomReleased],
|
|
142
|
+
handlers: {
|
|
143
|
+
RoomCreated: (state, value) => evolveRoomCreated(state, roomCreatedEvent(value)),
|
|
144
|
+
RoomReleased: (state, value) => evolveRoomReleased(state, roomReleasedEvent(value)),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const reservationProjector: ProjectorDefinition<ReservationState, "reservation", [typeof roomReserved, typeof reservationCancelled]> = projector({
|
|
149
|
+
id: "ReservationProjector",
|
|
150
|
+
version: 1,
|
|
151
|
+
tag: reservation,
|
|
152
|
+
state: reservationState,
|
|
153
|
+
events: [roomReserved, reservationCancelled],
|
|
154
|
+
handlers: {
|
|
155
|
+
RoomReserved: (state, value) => evolveRoomReserved(state, roomReservedEvent(value)),
|
|
156
|
+
ReservationCancelled: (state, value) => evolveReservationCancelled(state, reservationCancelledEvent(value)),
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
function validationDecision(result: {
|
|
161
|
+
readonly kind: "reject";
|
|
162
|
+
readonly rejectKind: RejectKind;
|
|
163
|
+
readonly reason: string;
|
|
164
|
+
readonly details?: unknown;
|
|
165
|
+
} | undefined) {
|
|
166
|
+
return result === undefined ? undefined : reject(result.rejectKind, result.reason, result.details);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const createRoomCommand = command({
|
|
170
|
+
id: "create-room",
|
|
171
|
+
input: roomInput,
|
|
172
|
+
reads: (input) => read(roomProjector, roomTag(input.roomId)),
|
|
173
|
+
handle: async (input, context) => {
|
|
174
|
+
const state = await context.state(roomProjector, roomTag(input.roomId));
|
|
175
|
+
const invalid = validationDecision(validateCreateRoom(state, input));
|
|
176
|
+
if (invalid !== undefined) return invalid;
|
|
177
|
+
context.append(roomCreated, roomCreated.make({ roomId: input.roomId, name: input.name ?? "" }));
|
|
178
|
+
return done({ roomId: input.roomId, name: input.name ?? "" });
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
export const reserveRoomCommand = command({
|
|
183
|
+
id: "reserve-room",
|
|
184
|
+
input: reservationInput,
|
|
185
|
+
reads: (input) => readSet(
|
|
186
|
+
read(roomProjector, roomTag(input.roomId)),
|
|
187
|
+
read(reservationProjector, reservationTag(input.reservationId)),
|
|
188
|
+
),
|
|
189
|
+
handle: async (input, context) => {
|
|
190
|
+
const roomValue = await context.state(roomProjector, roomTag(input.roomId));
|
|
191
|
+
const invalid = validationDecision(validateReserveRoom(roomValue, input));
|
|
192
|
+
if (invalid !== undefined) return invalid;
|
|
193
|
+
const reservationValue = await context.state(reservationProjector, reservationTag(input.reservationId));
|
|
194
|
+
if (reservationValue.status !== "empty") return reject("conflict", "reservation already exists", "reservation_exists");
|
|
195
|
+
context.append(roomReserved, roomReserved.make({
|
|
196
|
+
roomId: input.roomId,
|
|
197
|
+
reservationId: input.reservationId,
|
|
198
|
+
userId: input.userId ?? "",
|
|
199
|
+
}));
|
|
200
|
+
return done({ roomId: input.roomId, reservationId: input.reservationId });
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
export const cancelReservationCommand = command({
|
|
205
|
+
id: "cancel-reservation",
|
|
206
|
+
input: reservationOnlyInput,
|
|
207
|
+
reads: (input) => read(reservationProjector, reservationTag(input.reservationId)),
|
|
208
|
+
handle: async (input, context) => {
|
|
209
|
+
const state = await context.state(reservationProjector, reservationTag(input.reservationId));
|
|
210
|
+
const invalid = validationDecision(validateCancelReservation(state, input));
|
|
211
|
+
if (invalid !== undefined) return invalid;
|
|
212
|
+
const roomId = state.status === "empty" ? undefined : state.roomId;
|
|
213
|
+
context.append(reservationCancelled, reservationCancelled.make({
|
|
214
|
+
reservationId: input.reservationId,
|
|
215
|
+
...(roomId === null || roomId === undefined ? {} : { roomId }),
|
|
216
|
+
}));
|
|
217
|
+
return done({ reservationId: input.reservationId });
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
export const releaseRoomCommand = command({
|
|
222
|
+
id: "release-room",
|
|
223
|
+
input: roomInput,
|
|
224
|
+
reads: (input) => read(roomProjector, roomTag(input.roomId)),
|
|
225
|
+
handle: async (input, context) => {
|
|
226
|
+
const state = await context.state(roomProjector, roomTag(input.roomId));
|
|
227
|
+
const invalid = validationDecision(validateReleaseRoom(state, input));
|
|
228
|
+
if (invalid !== undefined) return invalid;
|
|
229
|
+
if (state.status === "released") return none("room is already released");
|
|
230
|
+
context.append(roomReleased, roomReleased.make({ roomId: input.roomId }));
|
|
231
|
+
return done({ roomId: input.roomId });
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const roomQuery = {
|
|
236
|
+
id: "GetRoomStateQuery",
|
|
237
|
+
version: 1,
|
|
238
|
+
queryType: "GetRoomStateQuery",
|
|
239
|
+
endpoint: "query",
|
|
240
|
+
tagGroup: "room",
|
|
241
|
+
tagProjector: "RoomProjector",
|
|
242
|
+
} satisfies { readonly id: string; readonly version: number; readonly queryType: string; readonly endpoint: "query"; readonly tagGroup: string; readonly tagProjector: string };
|
|
243
|
+
|
|
244
|
+
const reservationQuery = {
|
|
245
|
+
id: "GetReservationListQuery",
|
|
246
|
+
version: 1,
|
|
247
|
+
queryType: "GetReservationListQuery",
|
|
248
|
+
endpoint: "list-query",
|
|
249
|
+
tagGroup: "reservation",
|
|
250
|
+
tagProjector: "ReservationProjector",
|
|
251
|
+
} satisfies { readonly id: string; readonly version: number; readonly queryType: string; readonly endpoint: "list-query"; readonly tagGroup: string; readonly tagProjector: string };
|
|
252
|
+
|
|
253
|
+
export const bookingViews = Object.freeze([
|
|
254
|
+
{ id: "RoomProjector", source: "RoomProjector", projector: "RoomProjector", deliveryClass: "immediate-preferred" },
|
|
255
|
+
{ id: "ReservationProjector", source: "ReservationProjector", projector: "ReservationProjector", deliveryClass: "immediate-preferred" },
|
|
256
|
+
] satisfies readonly DomainViewDefinition[]);
|
|
257
|
+
|
|
258
|
+
export const bookingAuthoringDomain = domain({
|
|
259
|
+
events: [roomCreated, roomReserved, reservationCancelled, roomReleased],
|
|
260
|
+
commands: [createRoomCommand, reserveRoomCommand, cancelReservationCommand, releaseRoomCommand],
|
|
261
|
+
projectors: [roomProjector, reservationProjector],
|
|
262
|
+
views: bookingViews,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
export const bookingDomain: RuntimeDomainDefinition = toRuntimeDomain(bookingAuthoringDomain);
|
|
266
|
+
|
|
267
|
+
export const bookingRuntimeConfig = {
|
|
268
|
+
deliveryClass: "immediate-preferred",
|
|
269
|
+
projectorPayloadNames: { RoomProjector: "RoomState", ReservationProjector: "ReservationState" },
|
|
270
|
+
queries: [roomQuery, reservationQuery],
|
|
271
|
+
} satisfies {
|
|
272
|
+
readonly deliveryClass: "immediate-preferred" | "queued";
|
|
273
|
+
readonly projectorPayloadNames: Readonly<Record<string, string>>;
|
|
274
|
+
readonly queries: readonly {
|
|
275
|
+
readonly id: string;
|
|
276
|
+
readonly version: number;
|
|
277
|
+
readonly queryType: string;
|
|
278
|
+
readonly endpoint: "query" | "list-query";
|
|
279
|
+
readonly tagGroup: string;
|
|
280
|
+
readonly tagProjector: string;
|
|
281
|
+
}[];
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
export const bookingProjectors = { roomProjector, reservationProjector };
|
|
285
|
+
export const bookingCommands = { createRoomCommand, reserveRoomCommand, cancelReservationCommand, releaseRoomCommand };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { defineRowMaterializer } from "@sekiban/dcb-core";
|
|
2
|
+
import { createD1StoreProvider } from "@sekiban/dcb-runtime/d1";
|
|
3
|
+
import {
|
|
4
|
+
D1MaterializedViewStore,
|
|
5
|
+
MaterializedViewCatchUpRuntime,
|
|
6
|
+
type DeliveryViewFailureClass,
|
|
7
|
+
type DeliveryViewHandler,
|
|
8
|
+
type StoredEvent,
|
|
9
|
+
} from "@sekiban/dcb-runtime/d1-mv";
|
|
10
|
+
|
|
11
|
+
export interface BookingDataEnvironment {
|
|
12
|
+
readonly D1?: D1Database;
|
|
13
|
+
readonly D1_MV?: D1Database;
|
|
14
|
+
readonly SDT_SERVICE_ID?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function objectPayload(event: StoredEvent): Record<string, unknown> {
|
|
18
|
+
const value: unknown = JSON.parse(event.payload);
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stringField(value: unknown): string | undefined {
|
|
23
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const reservationMaterializer = defineRowMaterializer<StoredEvent>({
|
|
27
|
+
id: "ReservationProjector",
|
|
28
|
+
version: 1,
|
|
29
|
+
indexDescriptors: [{ id: "reservation-id", valueType: "text", value: (row) => (row as { reservationId?: unknown }).reservationId }],
|
|
30
|
+
materialize: (event) => {
|
|
31
|
+
if (!event.eventTags.some((tag) => tag.startsWith("reservation:"))) return {};
|
|
32
|
+
const payload = objectPayload(event);
|
|
33
|
+
const reservationId = stringField(payload.reservationId);
|
|
34
|
+
if (reservationId === undefined) return {};
|
|
35
|
+
if (event.eventType === "ReservationCancelled") {
|
|
36
|
+
return {
|
|
37
|
+
rowPatches: [{
|
|
38
|
+
kind: "json_patch" as const,
|
|
39
|
+
rowKey: reservationId,
|
|
40
|
+
patch: { status: "cancelled" },
|
|
41
|
+
rowVersion: 2,
|
|
42
|
+
}],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (event.eventType !== "RoomReserved") return {};
|
|
46
|
+
return {
|
|
47
|
+
rowUpserts: [{
|
|
48
|
+
rowKey: reservationId,
|
|
49
|
+
value: {
|
|
50
|
+
reservationId,
|
|
51
|
+
roomId: stringField(payload.roomId) ?? "",
|
|
52
|
+
userId: stringField(payload.userId) ?? "",
|
|
53
|
+
status: "reserved",
|
|
54
|
+
version: 1,
|
|
55
|
+
},
|
|
56
|
+
}],
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const roomMaterializer = defineRowMaterializer<StoredEvent>({
|
|
62
|
+
id: "RoomProjector",
|
|
63
|
+
version: 1,
|
|
64
|
+
indexDescriptors: [{ id: "room-id", valueType: "text", value: (row) => (row as { roomId?: unknown }).roomId }],
|
|
65
|
+
materialize: (event) => {
|
|
66
|
+
if (!event.eventTags.some((tag) => tag.startsWith("room:"))) return {};
|
|
67
|
+
const payload = objectPayload(event);
|
|
68
|
+
const roomId = stringField(payload.roomId);
|
|
69
|
+
if (roomId === undefined || (event.eventType !== "RoomCreated" && event.eventType !== "RoomReleased")) return {};
|
|
70
|
+
return {
|
|
71
|
+
rowUpserts: [{
|
|
72
|
+
rowKey: roomId,
|
|
73
|
+
value: {
|
|
74
|
+
roomId,
|
|
75
|
+
name: stringField(payload.name) ?? "",
|
|
76
|
+
status: event.eventType === "RoomCreated" ? "created" : "released",
|
|
77
|
+
version: event.eventType === "RoomCreated" ? 1 : 2,
|
|
78
|
+
},
|
|
79
|
+
}],
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const materializers = [reservationMaterializer, roomMaterializer] as const;
|
|
85
|
+
|
|
86
|
+
function requiredEnvironment(env: BookingDataEnvironment): { readonly D1: D1Database; readonly D1_MV: D1Database } {
|
|
87
|
+
if (env.D1 === undefined || env.D1_MV === undefined) throw new Error("Booking materializers require D1 and D1_MV bindings");
|
|
88
|
+
return { D1: env.D1, D1_MV: env.D1_MV };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function open(env: BookingDataEnvironment): Promise<{
|
|
92
|
+
readonly views: D1MaterializedViewStore;
|
|
93
|
+
readonly runtime: MaterializedViewCatchUpRuntime;
|
|
94
|
+
}> {
|
|
95
|
+
const bindings = requiredEnvironment(env);
|
|
96
|
+
const provider = createD1StoreProvider();
|
|
97
|
+
const source = provider.create(bindings);
|
|
98
|
+
await source.initialize();
|
|
99
|
+
const views = new D1MaterializedViewStore(bindings.D1_MV);
|
|
100
|
+
await views.initialize();
|
|
101
|
+
return { views, runtime: new MaterializedViewCatchUpRuntime(source, views) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function ensureBookingViews(env: BookingDataEnvironment, serviceId: string): Promise<void> {
|
|
105
|
+
const opened = await open(env);
|
|
106
|
+
for (const materializer of materializers) {
|
|
107
|
+
if (await opened.views.readActive(serviceId, materializer.id) === undefined) {
|
|
108
|
+
await opened.runtime.build(serviceId, materializer, Date.now());
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function catchUpBookingViews(env: BookingDataEnvironment, serviceId: string): Promise<void> {
|
|
114
|
+
const opened = await open(env);
|
|
115
|
+
for (const materializer of materializers) {
|
|
116
|
+
if (await opened.views.readActive(serviceId, materializer.id) === undefined) {
|
|
117
|
+
await opened.runtime.build(serviceId, materializer, Date.now());
|
|
118
|
+
} else {
|
|
119
|
+
await opened.runtime.follow(serviceId, materializer, Date.now());
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function bookingDeliveryViews(env: BookingDataEnvironment): readonly DeliveryViewHandler[] {
|
|
125
|
+
let opened: Promise<Awaited<ReturnType<typeof open>>> | undefined;
|
|
126
|
+
const context = async () => {
|
|
127
|
+
opened ??= (async () => {
|
|
128
|
+
const value = await open(env);
|
|
129
|
+
const serviceId = env.SDT_SERVICE_ID;
|
|
130
|
+
if (serviceId === undefined || serviceId.length === 0) throw new Error("SDT_SERVICE_ID is required");
|
|
131
|
+
await ensureBookingViews(env, serviceId);
|
|
132
|
+
return value;
|
|
133
|
+
})();
|
|
134
|
+
return opened;
|
|
135
|
+
};
|
|
136
|
+
return materializers.map((materializer) => ({
|
|
137
|
+
id: materializer.id,
|
|
138
|
+
admission: "independent-unsafe" as const,
|
|
139
|
+
apply: async ({ event, arrivedAt }) => {
|
|
140
|
+
const value = await context();
|
|
141
|
+
const serviceId = env.SDT_SERVICE_ID;
|
|
142
|
+
if (serviceId === undefined || serviceId.length === 0) throw new Error("SDT_SERVICE_ID is required");
|
|
143
|
+
const active = await value.views.readActive(serviceId, materializer.id);
|
|
144
|
+
if (active === undefined) throw new Error(`Materialized view ${materializer.id} is not initialized`);
|
|
145
|
+
const result = await value.views.unsafeWindow().apply({
|
|
146
|
+
serviceId,
|
|
147
|
+
viewId: materializer.id,
|
|
148
|
+
generation: active.generation,
|
|
149
|
+
eventId: event.eventId,
|
|
150
|
+
suid: event.suid,
|
|
151
|
+
safeHead: active.lastSuid,
|
|
152
|
+
updatedAt: arrivedAt,
|
|
153
|
+
recordArrival: true,
|
|
154
|
+
mutations: materializer.plan(event),
|
|
155
|
+
targetSuid: event.suid,
|
|
156
|
+
});
|
|
157
|
+
return result.duplicate ? "duplicate-race" : "applied";
|
|
158
|
+
},
|
|
159
|
+
classifyError: (error: unknown): DeliveryViewFailureClass => {
|
|
160
|
+
const typed = error as { readonly code?: unknown; readonly retryable?: unknown };
|
|
161
|
+
if (typed.code === "UNSAFE_DUPLICATE_RACE") return "duplicate-race";
|
|
162
|
+
if (typed.retryable === false) return "nonretryable-definition-poison";
|
|
163
|
+
return "retryable-transient";
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
}
|