@eventmodelers/cli 1.0.159 → 1.0.160
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/package.json +1 -1
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +54 -5
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +72 -9
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +17 -3
- package/stacks/supabase-react/templates/root/src/lib/api.ts +53 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.160",
|
|
4
4
|
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -63,6 +63,9 @@ Extract:
|
|
|
63
63
|
isn't built or re-fetched yet: fall back to `apiEndpoint` / `fields[]` as above and note the assumption
|
|
64
64
|
in `progress.txt`. If contract and `command.fields[]` disagree, the contract wins for the request and
|
|
65
65
|
you record the mismatch in `progress.txt`.
|
|
66
|
+
**Build the call from it**: `postCommand(buildPath('<contract path>', { <path params> }), { <requestBody
|
|
67
|
+
properties> })` — `buildPath` (in `src/lib/api.ts`) fills and URL-encodes `{param}` segments; the body
|
|
68
|
+
carries exactly the contract's `requestBody` properties (props the contract doesn't list stay out).
|
|
66
69
|
- **events[]** — informational only (what the backend is expected to emit); this stack does not
|
|
67
70
|
act on them directly.
|
|
68
71
|
- **screens[]** — the actor `SCREEN` element(s) that trigger this command, if any. Empty when the
|
|
@@ -127,11 +130,14 @@ lane concept, so the frontend path isn't a strict 1:1 mirror of it. `CommandTitl
|
|
|
127
130
|
`title` verbatim (e.g. `ReserveBike.tsx`).
|
|
128
131
|
|
|
129
132
|
**Markup**: translate the blueprint HTML from Step 2 into JSX **1:1** — same tags, same classes
|
|
130
|
-
(
|
|
133
|
+
(the screen's `cssFrameworks` framework), same layout and copy. This is not a redesign; the screen *is*
|
|
131
134
|
the design. Only change what must become dynamic:
|
|
132
135
|
- A field's example value becomes that input's controlled `value`.
|
|
133
136
|
- An element repeated for a `cardinality: "List"` field becomes a `.map()` over that field.
|
|
134
|
-
- `class`/`style`/structure otherwise stay exactly as drawn
|
|
137
|
+
- `class`/`style`/structure otherwise stay exactly as drawn — except the board's own mark plumbing:
|
|
138
|
+
drop every `data-em-mark-id` attribute and `em-mark`/`em-mark-<hex>` class (they only draw the
|
|
139
|
+
highlight outline on the canvas; the app never ships it).
|
|
140
|
+
- The classes only render correctly if the screen's CSS framework (`cssFrameworks`) is loaded in the app — see Step 7 (styles); don't hand-write CSS to compensate.
|
|
135
141
|
|
|
136
142
|
**Props**: exactly `command.fields[]`, typed per each field's `type` (`String`→`string`,
|
|
137
143
|
`Boolean`→`boolean`, `Int`/`Long`→`number`, etc. — `subfields[]` become a nested object type),
|
|
@@ -290,9 +296,52 @@ how many pages this app needs — not one page per slice.
|
|
|
290
296
|
- If `src/pages/<ScreenTitle>.tsx` already exists, update only this slice's marked position;
|
|
291
297
|
leave every other position (already-wired slices, and untouched mockup content) exactly as is.
|
|
292
298
|
|
|
293
|
-
## Step 7 —
|
|
294
|
-
|
|
295
|
-
|
|
299
|
+
## Step 7 — Load the screen's styles, every slice
|
|
300
|
+
|
|
301
|
+
The mockup's classes only mean something with the CSS framework the board renders it with, so the
|
|
302
|
+
app must load that same one. This runs on **every** slice (not just the first) and is idempotent —
|
|
303
|
+
an existing project that is missing it gets fixed on its next build.
|
|
304
|
+
|
|
305
|
+
1. **Detect** the framework the screen loads, from the node fetched in Step 2: `cssFrameworks` (one id).
|
|
306
|
+
If it isn't returned, infer it from the classes in `meta.pages` (`title`/`field`/`is-*` → bulma,
|
|
307
|
+
`btn`/`form-control`/`col-*` → bootstrap, utility classes like `flex p-6` → tailwind). If the
|
|
308
|
+
screen loads **no** framework (`[]`, or nothing detectable), use Vite's default: change nothing —
|
|
309
|
+
skip this whole step, leave `index.html` and `src/index.css` as the template ships them.
|
|
310
|
+
2. **Ensure it's loaded** in `index.html`'s `<head>`, same pinned version the board uses — check with
|
|
311
|
+
`grep -n -i "bulma\|bootstrap\|cdn.tailwindcss" index.html` first, add only what's missing:
|
|
312
|
+
- `bulma` → `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">`
|
|
313
|
+
- `bootstrap` → `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">`
|
|
314
|
+
- `tailwind` → `<script src="https://cdn.tailwindcss.com/3.4.16"></script>`
|
|
315
|
+
|
|
316
|
+
A *different* framework already there means two screens disagree: don't mix them — keep what's
|
|
317
|
+
there and flag it via `request-feedback`.
|
|
318
|
+
3. **Neutralise the Vite demo CSS** (only because a framework was detected). If `src/index.css` still holds the demo (it contains `#social` or
|
|
319
|
+
`--social-bg`), replace the whole file with `body { margin: 0; }` — the centred 1126px `#root`,
|
|
320
|
+
dark-mode palette, `color-scheme: light dark`, grey text and 56px `h1` all fight the mockup. A file
|
|
321
|
+
without those markers was already customised (or filled from `learn-styleguide`): leave it alone.
|
|
322
|
+
|
|
323
|
+
## Step 8 — Base page, only if this is the first slice
|
|
324
|
+
|
|
325
|
+
`src/App.tsx` ships as the Vite demo scaffold. The **first slice built in this project** replaces it
|
|
326
|
+
with the app's **base page**, so there is something real to open in the browser; every later slice
|
|
327
|
+
leaves it alone.
|
|
328
|
+
|
|
329
|
+
1. **First slice?** Yes only if `src/slices/` holds no other slice folder than this one (only
|
|
330
|
+
`.gitkeep` besides it) *and* `src/App.tsx` still contains the scaffold (`Get started` heading).
|
|
331
|
+
Otherwise skip this step entirely.
|
|
332
|
+
2. Rewrite `src/App.tsx` as the base page: a minimal `<main>` that renders the top-level thing this
|
|
333
|
+
slice produced — `src/pages/<ScreenTitle>.tsx` when Step 6 composed a shared page, else this
|
|
334
|
+
slice's own component. Nothing from the Vite demo (counter, logos, doc links) stays. No router and
|
|
335
|
+
no new dependency — a single page needs neither.
|
|
336
|
+
3. Delete what the scaffold alone used and nothing else now imports: `src/App.css` and
|
|
337
|
+
`src/assets/{hero.png,react.svg,vite.svg}` (check with `grep -rn` first); `public/icons.svg` too if
|
|
338
|
+
nothing references it. Leave `src/main.tsx` and `src/index.css` (Step 7 handles it) as they are.
|
|
339
|
+
4. Keep the page working with no backend, per the isolation rule in `.build-kit/CLAUDE.md`: the
|
|
340
|
+
slice component must render its empty state, and `VITE_DATA_MODE=mock` must show its sample.
|
|
341
|
+
|
|
342
|
+
## Step 9 — Verify against slice.json
|
|
343
|
+
|
|
344
|
+
After writing the hook/component (and context/page/base page, if any), confirm:
|
|
296
345
|
- Every prop matches a `command.fields[]` entry exactly (name + type) — no invented or missing
|
|
297
346
|
fields.
|
|
298
347
|
- The `postCommand` call uses the UI contract's `path` and `body` when one was found, else `command.apiEndpoint` verbatim — or, when it was missing at build
|
|
@@ -29,7 +29,8 @@ convenient — for a component to read it straight from Supabase, with no bespok
|
|
|
29
29
|
between, the same way `miro-eventmodeling` and this stack's other Supabase-backed components do.
|
|
30
30
|
|
|
31
31
|
That said, a component never calls `supabase.from(...)` (or imports `src/lib/supabase.ts`) itself —
|
|
32
|
-
it calls `
|
|
32
|
+
it calls `getReadModel(...)` (backend `GET` UI contract available) or `queryReadModel(...)` (table
|
|
33
|
+
read) from `src/lib/api.ts`, same as `build-state-change` components only
|
|
33
34
|
ever call `postCommand`. `queryReadModel` is the real Supabase call in production, but in mock mode
|
|
34
35
|
it serves a numbered sample instead (see Step 4) — routing every read through this one seam is what
|
|
35
36
|
makes a component testable/previewable in isolation, with zero Supabase project needed.
|
|
@@ -66,10 +67,22 @@ Extract:
|
|
|
66
67
|
a Slice" step 6) — the built, mocked component still needs a real table/view before it's done,
|
|
67
68
|
so don't flip it to `Done`.
|
|
68
69
|
- `description` — implementation hints (filtering rules, sort order, business meaning).
|
|
69
|
-
- **
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
- **UI contract (read)** — the backend slice's published `GET` contract: look it up as described in
|
|
71
|
+
the `ui-contract` skill ("How the UI build kit looks the contract up" — the `## UI Contract` entry in
|
|
72
|
+
`slice.json` `notes[]`; parse only its `ui-contract` block — an OpenAPI operation). **When present it
|
|
73
|
+
is the specification and you build the call from it** — it overrides `apiEndpoint` and the table read:
|
|
74
|
+
- fetch with `getReadModel(buildPath('<contract path>', { <path params> }), { <query params> }, samples)`
|
|
75
|
+
from `src/lib/api.ts` — path and query `parameters` exactly as the contract names them, never a
|
|
76
|
+
guessed URL, never `supabase.from(...)`;
|
|
77
|
+
- type the result from the 2xx `responses` schema (it must match `readmodel.fields[]`; the contract
|
|
78
|
+
wins for the wire shape, record any mismatch in `progress.txt`);
|
|
79
|
+
- render **every** non-2xx response (status + description) as its own distinct state — a 404 "no
|
|
80
|
+
reservation" is an empty state, not an error banner; don't collapse them into one generic failure;
|
|
81
|
+
- the numbered samples (Step 4) are shaped like the contract's 2xx response body.
|
|
82
|
+
|
|
83
|
+
**When absent** (backend slice not built or not re-fetched yet): fall back to the table read —
|
|
84
|
+
`readmodel.apiEndpoint` (table/view name) + `fields[]` via `queryReadModel` — and note the assumption
|
|
85
|
+
in `progress.txt`.
|
|
73
86
|
- **screens[]** — the actor `SCREEN` element(s) that display this data, if any (see Step 2).
|
|
74
87
|
- **specifications[]** — GWT scenarios/storyline; treat as acceptance criteria (e.g. "given no
|
|
75
88
|
active reservation, show an empty state").
|
|
@@ -103,8 +116,13 @@ the mockup is the design, not a starting point for a redesign.
|
|
|
103
116
|
its parent, never read from a global store. The queried data itself is **not** a prop — the
|
|
104
117
|
component fetches it.
|
|
105
118
|
|
|
106
|
-
**
|
|
107
|
-
|
|
119
|
+
**With a UI contract (Step 1)**: call `getReadModel(buildPath(path, pathParams), queryParams, samples)`
|
|
120
|
+
from `src/lib/api.ts` inside the component instead of the `queryReadModel` snippet below — same
|
|
121
|
+
effect shape (`useEffect` → loading / empty / error / data), but each documented non-2xx response
|
|
122
|
+
(`ApiError.status`) maps to its own state. Never a raw `fetch`.
|
|
123
|
+
|
|
124
|
+
**Default (no contract) — call `queryReadModel` from `src/lib/api.ts` inside the component**. Never
|
|
125
|
+
`supabase.from(...)` directly, never a raw `fetch`:
|
|
108
126
|
|
|
109
127
|
```tsx
|
|
110
128
|
// ActiveReservationView.tsx
|
|
@@ -196,7 +214,7 @@ component doesn't do anything with that number itself, `queryReadModel` reads it
|
|
|
196
214
|
|
|
197
215
|
## Step 5 — Compose onto the shared page, if this screen's name is shared
|
|
198
216
|
|
|
199
|
-
Identical to `build-state-change` Step
|
|
217
|
+
Identical to `build-state-change` Step 6 — a screen's **title is the page name**, and the number
|
|
200
218
|
of *distinct* screen titles across the board is a direct hint at how many pages this app needs, not
|
|
201
219
|
one page per slice.
|
|
202
220
|
|
|
@@ -216,10 +234,55 @@ one page per slice.
|
|
|
216
234
|
slice component still only knows its own declared props. Update only this slice's marked
|
|
217
235
|
position if the page file already exists.
|
|
218
236
|
|
|
219
|
-
## Step 6 —
|
|
237
|
+
## Step 6 — Load the screen's styles, every slice
|
|
238
|
+
|
|
239
|
+
The mockup's classes only mean something with the CSS framework the board renders it with, so the
|
|
240
|
+
app must load that same one. This runs on **every** slice (not just the first) and is idempotent —
|
|
241
|
+
an existing project that is missing it gets fixed on its next build.
|
|
242
|
+
|
|
243
|
+
1. **Detect** the framework the screen loads, from the node fetched in Step 2: `cssFrameworks` (one id).
|
|
244
|
+
If it isn't returned, infer it from the classes in `meta.pages` (`title`/`field`/`is-*` → bulma,
|
|
245
|
+
`btn`/`form-control`/`col-*` → bootstrap, utility classes like `flex p-6` → tailwind). If the
|
|
246
|
+
screen loads **no** framework (`[]`, or nothing detectable), use Vite's default: change nothing —
|
|
247
|
+
skip this whole step, leave `index.html` and `src/index.css` as the template ships them.
|
|
248
|
+
2. **Ensure it's loaded** in `index.html`'s `<head>`, same pinned version the board uses — check with
|
|
249
|
+
`grep -n -i "bulma\|bootstrap\|cdn.tailwindcss" index.html` first, add only what's missing:
|
|
250
|
+
- `bulma` → `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">`
|
|
251
|
+
- `bootstrap` → `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">`
|
|
252
|
+
- `tailwind` → `<script src="https://cdn.tailwindcss.com/3.4.16"></script>`
|
|
253
|
+
|
|
254
|
+
A *different* framework already there means two screens disagree: don't mix them — keep what's
|
|
255
|
+
there and flag it via `request-feedback`.
|
|
256
|
+
3. **Neutralise the Vite demo CSS** (only because a framework was detected). If `src/index.css` still holds the demo (it contains `#social` or
|
|
257
|
+
`--social-bg`), replace the whole file with `body { margin: 0; }` — the centred 1126px `#root`,
|
|
258
|
+
dark-mode palette, `color-scheme: light dark`, grey text and 56px `h1` all fight the mockup. A file
|
|
259
|
+
without those markers was already customised (or filled from `learn-styleguide`): leave it alone.
|
|
260
|
+
|
|
261
|
+
## Step 7 — Base page, only if this is the first slice
|
|
262
|
+
|
|
263
|
+
`src/App.tsx` ships as the Vite demo scaffold. The **first slice built in this project** replaces it
|
|
264
|
+
with the app's **base page**, so there is something real to open in the browser; every later slice
|
|
265
|
+
leaves it alone.
|
|
266
|
+
|
|
267
|
+
1. **First slice?** Yes only if `src/slices/` holds no other slice folder than this one (only
|
|
268
|
+
`.gitkeep` besides it) *and* `src/App.tsx` still contains the scaffold (`Get started` heading).
|
|
269
|
+
Otherwise skip this step entirely.
|
|
270
|
+
2. Rewrite `src/App.tsx` as the base page: a minimal `<main>` that renders the top-level thing this
|
|
271
|
+
slice produced — `src/pages/<ScreenTitle>.tsx` when Step 5 composed a shared page, else this
|
|
272
|
+
slice's own component. Nothing from the Vite demo (counter, logos, doc links) stays. No router and
|
|
273
|
+
no new dependency — a single page needs neither.
|
|
274
|
+
3. Delete what the scaffold alone used and nothing else now imports: `src/App.css` and
|
|
275
|
+
`src/assets/{hero.png,react.svg,vite.svg}` (check with `grep -rn` first); `public/icons.svg` too if
|
|
276
|
+
nothing references it. Leave `src/main.tsx` and `src/index.css` (Step 6 handles it) as they are.
|
|
277
|
+
4. Keep the page working with no backend, per the isolation rule in `.build-kit/CLAUDE.md`: the
|
|
278
|
+
slice component must render its empty state, and `VITE_DATA_MODE=mock` must show its sample.
|
|
279
|
+
|
|
280
|
+
## Step 8 — Verify against slice.json
|
|
220
281
|
|
|
221
282
|
- Every rendered field traces back to a `readmodel.fields[]` entry — no invented or missing
|
|
222
283
|
fields.
|
|
284
|
+
- With a UI contract: the `getReadModel` call uses the contract's path and query `parameters`
|
|
285
|
+
verbatim, and every non-2xx response has its own rendered state. Otherwise:
|
|
223
286
|
- The `queryReadModel` call targets `readmodel.apiEndpoint` verbatim as `table` — or, when it was
|
|
224
287
|
missing at build time, the provisional `mock_...` table name noted in a comment (Step 1) — never a
|
|
225
288
|
raw `fetch`, a direct `supabase.from(...)` call, or a hardcoded table name that doesn't match
|
|
@@ -11,8 +11,9 @@ not attempt to build it, flag it via `request-feedback` instead.
|
|
|
11
11
|
## File Structure Constraints
|
|
12
12
|
|
|
13
13
|
- **Strict Path Limitation**: `src/slices/{contextSlug}/{laneSlug}/{slicename}/*` — the one
|
|
14
|
-
|
|
15
|
-
guideline
|
|
14
|
+
exceptions are `src/pages/`, where multiple slices sharing one screen get composed together (see
|
|
15
|
+
guideline 7 below), and — for the **first slice only** — `src/App.tsx` plus the Vite demo files it
|
|
16
|
+
replaces (guideline 10 below); `index.html` and the demo `src/index.css` for every slice (guideline 11)
|
|
16
17
|
- **Slice Organization**: Each feature/domain should be organized as a separate slice
|
|
17
18
|
|
|
18
19
|
## Code Standards
|
|
@@ -27,7 +28,7 @@ not attempt to build it, flag it via `request-feedback` instead.
|
|
|
27
28
|
2. Maintain clear separation of concerns within each slice
|
|
28
29
|
3. Headers, auth, `fetch`, and Supabase table access live exclusively in `src/lib/api.ts` — a
|
|
29
30
|
slice component never imports `src/lib/supabase.ts` itself, never calls `fetch`, and never
|
|
30
|
-
calls `supabase.from(...)` directly; it calls `postCommand`/`queryReadModel` and only contains
|
|
31
|
+
calls `supabase.from(...)` directly; when the backend slice published a UI contract (`ui-contract` skill, `slice.json` `notes[]`) the slice is built *from* it — path, parameters, body and every error response — and falls back to `apiEndpoint`/`fields[]` only when none exists; it calls `postCommand`/`getReadModel`/`queryReadModel` and only contains
|
|
31
32
|
business logic (validation, request-body shaping, response handling)
|
|
32
33
|
4. A command's/read-model's screen markup (`meta.pages` on its actor HTML_SCREEN node, scoped to
|
|
33
34
|
this slice's `meta.marks` when the screen is shared) is the visual blueprint — translate it into
|
|
@@ -82,6 +83,18 @@ not attempt to build it, flag it via `request-feedback` instead.
|
|
|
82
83
|
the app, not an arbitrary per-component index. See `build-state-change`/`build-state-view`'s
|
|
83
84
|
samples step for the full convention.
|
|
84
85
|
|
|
86
|
+
10. **The first slice also builds the base page.** `src/App.tsx` ships as the Vite demo. When the
|
|
87
|
+
slice being built is the first one in the project (no other folder under `src/slices/`), replace
|
|
88
|
+
it with a minimal base page rendering that slice's page or component and delete the demo-only
|
|
89
|
+
files; no router, no new dependency. Later slices never touch `src/App.tsx`. See
|
|
90
|
+
`build-state-change` Step 8 / `build-state-view` Step 7.
|
|
91
|
+
11. **Every slice loads its screen's styles.** The mockup's classes are meaningless without the CSS
|
|
92
|
+
framework the board renders them with. Each slice detects it (`cssFrameworks`, else the classes
|
|
93
|
+
in `meta.pages`; none → Vite's default stays as is), makes sure `index.html` loads it, and neutralises the Vite demo
|
|
94
|
+
`src/index.css` if it's still there — automatically, idempotently, so an existing project heals
|
|
95
|
+
on its next build. Never hand-write CSS to imitate a framework. See `build-state-change` Step 7 /
|
|
96
|
+
`build-state-view` Step 6.
|
|
97
|
+
|
|
85
98
|
At the start of every session, read `.build-kit/AGENTS.md` if it exists to load accumulated project learnings.
|
|
86
99
|
|
|
87
100
|
When starting to work on a slice, invoke the `update-slice-status` skill with `InProgress` status before doing anything else.
|
|
@@ -124,6 +137,7 @@ src/lib/supabase.ts # Supabase client singleton
|
|
|
124
137
|
src/lib/api.ts # postCommand/queryReadModel + auth headers + mock-mode switch (shared, not per-slice)
|
|
125
138
|
src/lib/<CrossCutting>Provider.tsx # only for concerns every slice might need (session, theme, ...)
|
|
126
139
|
src/pages/<ScreenTitle>.tsx # only when >1 slice shares a screen title — composes their components
|
|
140
|
+
src/App.tsx # base page — replaces the Vite demo when the first slice is built, untouched afterwards
|
|
127
141
|
src/slices/<contextSlug>/<laneSlug>/<sliceFolder>/
|
|
128
142
|
<CommandTitle>.tsx # STATE_CHANGE slice — component (logic inline by default)
|
|
129
143
|
use<CommandTitle>.ts # only if the logic was genuinely worth splitting out
|
|
@@ -84,6 +84,58 @@ export async function postCommand<TResponse = unknown>(
|
|
|
84
84
|
return payload as TResponse;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Fill an OpenAPI path template (`/api/customers/{customerId}/reservation`) with values,
|
|
89
|
+
* URL-encoding each one. Throws if a `{param}` has no value — a contract's path parameters are
|
|
90
|
+
* required. Use the contract's path verbatim as `template`; never hand-assemble a URL.
|
|
91
|
+
*/
|
|
92
|
+
export function buildPath(template: string, params: Record<string, string | number> = {}): string {
|
|
93
|
+
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
|
|
94
|
+
const value = params[name];
|
|
95
|
+
if (value === undefined) throw new Error(`Missing path parameter "${name}" for ${template}`);
|
|
96
|
+
return encodeURIComponent(String(value));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mockSample<T>(samples: Record<string, T> | undefined, what: string): T {
|
|
101
|
+
const n = activeMockSample();
|
|
102
|
+
const sample = samples?.[n];
|
|
103
|
+
if (sample === undefined) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Mock mode is on (VITE_MOCK_SAMPLE=${n}) but no sample-${n}.json exists for ${what} — add one under this component's samples/ folder.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return sample;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* GET a read model from the backend's published `GET` UI contract (`path` already filled via
|
|
113
|
+
* `buildPath`, `query` = the contract's query `parameters`). Resolves with the parsed JSON, or
|
|
114
|
+
* throws `ApiError` on a non-2xx response — the status and body let a component render each
|
|
115
|
+
* documented error response as its own state. Mock mode serves `samples` like `queryReadModel`.
|
|
116
|
+
*/
|
|
117
|
+
export async function getReadModel<T>(
|
|
118
|
+
path: string,
|
|
119
|
+
query?: Record<string, string | number | boolean | undefined>,
|
|
120
|
+
samples?: Record<string, T>,
|
|
121
|
+
): Promise<T> {
|
|
122
|
+
if (isMockMode()) return mockSample(samples, `GET ${path}`);
|
|
123
|
+
|
|
124
|
+
const qs = new URLSearchParams(
|
|
125
|
+
Object.entries(query ?? {}).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)]),
|
|
126
|
+
).toString();
|
|
127
|
+
const headers = await getAuthHeaders();
|
|
128
|
+
const res = await fetch(`${API_BASE}${path}${qs ? `?${qs}` : ''}`, { method: 'GET', headers });
|
|
129
|
+
|
|
130
|
+
const payload = await res.json().catch(() => undefined);
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
const message =
|
|
133
|
+
(payload && (payload.error || payload.message)) || `Request failed with status ${res.status}`;
|
|
134
|
+
throw new ApiError(message, res.status, payload);
|
|
135
|
+
}
|
|
136
|
+
return payload as T;
|
|
137
|
+
}
|
|
138
|
+
|
|
87
139
|
export interface ReadModelParams {
|
|
88
140
|
/** `readmodel.apiEndpoint`, verbatim — the Postgres table/view name. */
|
|
89
141
|
table: string;
|
|
@@ -105,16 +157,7 @@ export async function queryReadModel<T>(
|
|
|
105
157
|
params: ReadModelParams,
|
|
106
158
|
samples?: Record<string, T>,
|
|
107
159
|
): Promise<T> {
|
|
108
|
-
if (isMockMode()) {
|
|
109
|
-
const n = activeMockSample();
|
|
110
|
-
const sample = samples?.[n];
|
|
111
|
-
if (sample === undefined) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
`Mock mode is on (VITE_MOCK_SAMPLE=${n}) but no sample-${n}.json exists for table "${params.table}" — add one under this component's samples/ folder.`,
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
return sample;
|
|
117
|
-
}
|
|
160
|
+
if (isMockMode()) return mockSample(samples, `table "${params.table}"`);
|
|
118
161
|
|
|
119
162
|
let query = supabase.from(params.table).select(params.select);
|
|
120
163
|
for (const [column, value] of Object.entries(params.filters ?? {})) {
|