@ogygie/core 0.10.2 → 0.11.0

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/README.md CHANGED
@@ -17,7 +17,8 @@ dans `apps/`.
17
17
  | `@ogygie/core/vocabulary` | Appariement des racines de vocabulaire sur les identifiants machine — les racines appartiennent au produit |
18
18
  | `@ogygie/core/vocabulary-audit` | Jugement du gate de vocabulaire : alias interdits du code, pureté des fichiers gouvernés |
19
19
  | `@ogygie/core/gateway` | Passerelle publique dérivée du manifeste (ci-dessous) |
20
- | `@ogygie/core/client` | Client d'API front dérivé du manifeste (clés de cache, invalidations) |
20
+ | `@ogygie/core/client` | Client d'API front dérivé du manifeste : transport (`fetchImpl` injecté), clés de cache et invalidations (D13) |
21
+ | `@ogygie/core/client-react` | Les hooks du même client — `react` et `@tanstack/react-query` en peers OPTIONNELS, chargés dynamiquement (D13) |
21
22
 
22
23
  ## Le test du vocabulaire couvre TOUT le core
23
24
 
@@ -142,3 +143,68 @@ et les `Set-Cookie` de l'amont, eux, traversent **octet pour octet** — une
142
143
  | `GATEWAY_ROUTE_UNKNOWN` | 404 | Aucune route déclarée ne correspond (et aucun fallback SPA applicable). |
143
144
  | `GATEWAY_UPSTREAM_UNAVAILABLE` | 502 | L'amont est injoignable. Aucun détail interne ne fuit : ni URL privée, ni stack. |
144
145
  | `GATEWAY_UPSTREAM_TIMEOUT` | 504 | L'amont n'a pas répondu dans `timeoutMs`. |
146
+
147
+ ## Client d'API dérivé du manifeste — `@ogygie/core/client` et `/client-react`
148
+
149
+ `createManifestClient(manifest, schemas, options)` est le **pendant front** de
150
+ la passerelle (spec `manifest-client`, D13) : chaque route déclarée devient une
151
+ méthode, sous le namespace de sa racine de chemin. Une route absente du
152
+ manifeste n'existe pas côté client. Les **clés de cache** et les
153
+ **invalidations** sont DÉRIVÉES — un produit n'écrit plus une seule clé à la
154
+ main, ni un seul `fetch`.
155
+
156
+ ### Usage minimal
157
+
158
+ ```ts
159
+ import { createManifestClient } from "@ogygie/core/client";
160
+ import { createApiHooks } from "@ogygie/core/client-react";
161
+ import * as widgetSchemas from "@my-product/api/features/widgets/schemas";
162
+
163
+ const api = createManifestClient(manifest, { ...widgetSchemas }, {
164
+ fetchImpl: globalThis.fetch, // effet injecté, jamais importé au fond d'un helper
165
+ validateResponses: import.meta.env.DEV ? "always" : "never",
166
+ });
167
+
168
+ api.widgets.list(); // GET /api/widgets
169
+ api.widgets.get({ widgetId }); // GET /api/widgets/:widgetId
170
+ api.widgets.list.queryKey(); // ["widgets", "list"]
171
+ api.widgets.create.invalidationRoots; // ["widgets", ...invalidates du manifeste]
172
+
173
+ // Les hooks : peers OPTIONNELS, chargés dynamiquement (refus structuré si absents).
174
+ const { useApiQuery, useApiMutation } = await createApiHooks(queryClient);
175
+ useApiQuery(api.widgets.list);
176
+ useApiMutation(api.widgets.create); // invalide ["widgets"] + les racines déclarées
177
+ ```
178
+
179
+ ### Dérivations
180
+
181
+ | Élément | Règle |
182
+ |---|---|
183
+ | Namespace | Premier segment après `/api`, camelCasé (`/api/work-orders/...` → `client.workOrders`). |
184
+ | Opération | Segments statiques suivants (`.../:id/archive` → `archive`) ; sinon le VERBE du nom du schéma d'entrée (`SearchReportsInputSchema` → `search`) ; sinon la méthode (`GET` → `list`/`get`, `POST` → `create`, `PATCH`/`PUT` → `update`, `DELETE` → `delete`). |
185
+ | Clé de cache | `[racine, opération, ...valeurs des params, reste de l'entrée]`. L'opération EN FAIT PARTIE : deux GET voisins sur la même entité partageraient sinon une entrée de cache, et le second ne partirait jamais. |
186
+ | Invalidation | Racine propre + les racines déclarées dans `invalidates` de la route (vérifiées par `verify-manifest` : une cible fantôme casse le build). |
187
+
188
+ ### Options
189
+
190
+ | Option | Requis | Effet |
191
+ |---|---|---|
192
+ | `fetchImpl` | oui | L'effet réseau, injecté (`globalThis.fetch`, ou un faux en test). Le module n'appelle jamais le réseau autrement. |
193
+ | `validateResponses` | oui | `"always"` reparse chaque réponse avec son `schemaOutput` (dev : un contrat cassé échoue ICI) ; `"never"` = passthrough. Le PRODUIT choisit par environnement — le core ne lit aucune variable d'env. |
194
+ | `baseUrl` | non | Préfixe du chemin dérivé ; vide par défaut, le front appelle son api en relatif. |
195
+
196
+ ### Erreurs
197
+
198
+ | Code | Quand |
199
+ |---|---|
200
+ | `MANIFEST_CLIENT_INVALID_MANIFEST` | **À la construction** : manifeste illisible, chemin malformé, route déclarée deux fois. |
201
+ | `MANIFEST_CLIENT_UNKNOWN_SCHEMA` | **À la construction** : un `schemaInput`/`schemaOutput` du manifeste manque au registre. |
202
+ | `MANIFEST_CLIENT_AMBIGUOUS_OPERATION` | **À la construction** : deux routes dérivent le même nom d'opération — jamais un écrasement silencieux. |
203
+ | `MANIFEST_CLIENT_MISSING_PATH_PARAM` | Appel sans le param de chemin que la route exige, avant tout appel réseau. |
204
+ | `NETWORK_ERROR` | `fetchImpl` a rejeté, ou l'api a échoué SANS corps `OgygieError` (statut conservé). |
205
+ | `CONTRACT_BROKEN` | Réponse non-JSON, ou violant `schemaOutput` en `validateResponses: "always"`. |
206
+ | `CLIENT_REACT_PEER_MISSING` | `client-react` sans `@tanstack/react-query` installé : refus nommant la commande, jamais une erreur de résolution de module. |
207
+
208
+ Un corps `OgygieError` renvoyé par l'api, lui, **ressort intact** (code,
209
+ `details`, `suggestedFix`, statut) : c'est la seule chose sur laquelle un écran
210
+ puisse agir.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygie/core",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,15 +33,18 @@
33
33
  "./shared-tables": "./src/shared-tables.ts",
34
34
  "./test-kit": "./src/test-kit.ts",
35
35
  "./gateway": "./src/manifest-gateway.ts",
36
- "./client": "./src/manifest-client.ts"
36
+ "./client": "./src/manifest-client.ts",
37
+ "./client-react": "./src/manifest-client-react.ts"
37
38
  },
38
39
  "peerDependencies": {
39
40
  "@electric-sql/pglite": "^0.3.0",
40
41
  "@fastify/helmet": "^13.0.0",
41
42
  "@fastify/rate-limit": "^10.0.0",
42
43
  "@fastify/static": "^10.1.2",
44
+ "@tanstack/react-query": "^5.0.0",
43
45
  "drizzle-orm": "^0.45.2",
44
46
  "fastify": "^5.0.0",
47
+ "react": "^19.0.0",
45
48
  "zod": "^4.0.0"
46
49
  },
47
50
  "peerDependenciesMeta": {
@@ -57,11 +60,17 @@
57
60
  "@fastify/static": {
58
61
  "optional": true
59
62
  },
63
+ "@tanstack/react-query": {
64
+ "optional": true
65
+ },
60
66
  "drizzle-orm": {
61
67
  "optional": true
62
68
  },
63
69
  "fastify": {
64
70
  "optional": true
71
+ },
72
+ "react": {
73
+ "optional": true
65
74
  }
66
75
  },
67
76
  "devDependencies": {
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @intent Proves the behaviors of specs/manifest-client.spec.md §2: the hooks
3
+ * carry the DERIVED cache key and the invalidations a successful mutation
4
+ * implies — a screen writes neither — and the optional peer is refused
5
+ * STRUCTURALLY when it is missing (this repo deliberately does not install it,
6
+ * so that last test is the real thing, not a simulation).
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { z } from "zod";
10
+ import { OgygieFailure } from "./error.schema.js";
11
+ import {
12
+ createManifestClient,
13
+ type ApiOperation,
14
+ type ManifestClient,
15
+ } from "./manifest-client.js";
16
+ import {
17
+ createApiHooks,
18
+ loadReactQuery,
19
+ staleQueryKeysOf,
20
+ type ApiMutationOptions,
21
+ type ApiQueryOptions,
22
+ type ReactQueryLike,
23
+ } from "./manifest-client-react.js";
24
+
25
+ /** Reads a derived operation, or fails the test — never a cast. */
26
+ function operationOf(client: ManifestClient, namespace: string, operation: string): ApiOperation {
27
+ const found = client[namespace]?.[operation];
28
+ if (found === undefined) throw new Error(`fixture client has no ${namespace}.${operation}`);
29
+ return found;
30
+ }
31
+
32
+ function fixtureClient() {
33
+ const schemas: Record<string, z.ZodType> = {
34
+ ListWidgetsInputSchema: z.record(z.string(), z.unknown()),
35
+ ListWidgetsOutputSchema: z.record(z.string(), z.unknown()),
36
+ CreateWidgetInputSchema: z.record(z.string(), z.unknown()),
37
+ CreateWidgetOutputSchema: z.record(z.string(), z.unknown()),
38
+ };
39
+ const manifest = {
40
+ features: [
41
+ {
42
+ name: "widgets",
43
+ routes: [
44
+ {
45
+ method: "GET",
46
+ path: "/api/widgets",
47
+ schemaInput: "ListWidgetsInputSchema",
48
+ schemaOutput: "ListWidgetsOutputSchema",
49
+ },
50
+ {
51
+ method: "POST",
52
+ path: "/api/widgets",
53
+ schemaInput: "CreateWidgetInputSchema",
54
+ schemaOutput: "CreateWidgetOutputSchema",
55
+ invalidates: ["work-orders"],
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ };
61
+ const calls: string[] = [];
62
+ const client = createManifestClient(manifest, schemas, {
63
+ fetchImpl: async (url) => {
64
+ calls.push(url);
65
+ return { ok: true, status: 200, text: async () => JSON.stringify({ ok: true }) };
66
+ },
67
+ validateResponses: "never",
68
+ });
69
+ return { client, calls };
70
+ }
71
+
72
+ /** The peer, faked: it only has to record what the hooks hand it. */
73
+ function fakeReactQuery(): {
74
+ module: ReactQueryLike;
75
+ queries: ApiQueryOptions[];
76
+ mutations: ApiMutationOptions[];
77
+ } {
78
+ const queries: ApiQueryOptions[] = [];
79
+ const mutations: ApiMutationOptions[] = [];
80
+ return {
81
+ queries,
82
+ mutations,
83
+ module: {
84
+ useQuery: (options: ApiQueryOptions) => {
85
+ queries.push(options);
86
+ return { data: undefined, isPending: true, isError: false, error: null };
87
+ },
88
+ useMutation: (options: ApiMutationOptions) => {
89
+ mutations.push(options);
90
+ return { mutate: () => undefined, isPending: false };
91
+ },
92
+ },
93
+ };
94
+ }
95
+
96
+ function recordingQueryClient(): {
97
+ queryClient: { invalidateQueries: (filters: { queryKey: ReadonlyArray<unknown> }) => unknown };
98
+ invalidated: ReadonlyArray<unknown>[];
99
+ } {
100
+ const invalidated: ReadonlyArray<unknown>[] = [];
101
+ return {
102
+ invalidated,
103
+ queryClient: {
104
+ invalidateQueries: (filters) => {
105
+ invalidated.push(filters.queryKey);
106
+ return undefined;
107
+ },
108
+ },
109
+ };
110
+ }
111
+
112
+ describe("manifest-client-react — hooks dérivés (spec manifest-client §2)", () => {
113
+ it("useApiQuery lit par la clé DÉRIVÉE et appelle l'opération, sans une ligne au site d'appel", async () => {
114
+ const { client, calls } = fixtureClient();
115
+ const peer = fakeReactQuery();
116
+ const { queryClient } = recordingQueryClient();
117
+ const hooks = await createApiHooks(queryClient, peer.module);
118
+
119
+ hooks.useApiQuery(operationOf(client, "widgets", "list"), { q: "ab" });
120
+
121
+ expect(peer.queries[0]?.queryKey).toEqual(["widgets", "list", { q: "ab" }]);
122
+ await peer.queries[0]?.queryFn();
123
+ expect(calls).toEqual(["/api/widgets?q=ab"]);
124
+ });
125
+
126
+ it("ÉTANT DONNÉ une mutation réussie ALORS sa racine est invalidée sans une ligne dans le code appelant", async () => {
127
+ const { client } = fixtureClient();
128
+ const peer = fakeReactQuery();
129
+ const { queryClient, invalidated } = recordingQueryClient();
130
+ const hooks = await createApiHooks(queryClient, peer.module);
131
+
132
+ hooks.useApiMutation(operationOf(client, "widgets", "create"));
133
+ await peer.mutations[0]?.onSuccess();
134
+
135
+ // Own root FIRST, then the cross-invalidation the manifest declares.
136
+ expect(invalidated).toEqual([["widgets"], ["work-orders"]]);
137
+ });
138
+
139
+ it("ÉTANT DONNÉ une route déclarant invalidates ALORS la dépendance croisée est LUE, jamais devinée", () => {
140
+ const { client } = fixtureClient();
141
+ expect(staleQueryKeysOf(operationOf(client, "widgets", "create"))).toEqual([
142
+ ["widgets"],
143
+ ["work-orders"],
144
+ ]);
145
+ expect(staleQueryKeysOf(operationOf(client, "widgets", "list"))).toEqual([["widgets"]]);
146
+ });
147
+
148
+ it("ÉTANT DONNÉ client-react chargé sans @tanstack/react-query ALORS refus structuré nommant la commande", async () => {
149
+ // The absence is SIMULATED at the importer, not at the disk: the framework
150
+ // repo installs the peer the day it declares it (pnpm resolves declared
151
+ // peers), and a behavior may not stop being proven because the machine
152
+ // happens to satisfy it.
153
+ let thrown: unknown;
154
+ try {
155
+ await loadReactQuery(() =>
156
+ Promise.reject(new Error("Cannot find package '@tanstack/react-query'")),
157
+ );
158
+ } catch (failure) {
159
+ thrown = failure;
160
+ }
161
+ expect(thrown).toBeInstanceOf(OgygieFailure);
162
+ const failure = thrown as OgygieFailure;
163
+ expect(failure.error.code).toBe("CLIENT_REACT_PEER_MISSING");
164
+ expect(failure.error.suggestedFix).toContain("pnpm add react @tanstack/react-query");
165
+ });
166
+
167
+ it("ÉTANT DONNÉ le peer installé ALORS l'import dynamique rend de vrais hooks", async () => {
168
+ const { queryClient } = recordingQueryClient();
169
+ const hooks = await createApiHooks(queryClient);
170
+ expect(typeof hooks.useApiQuery).toBe("function");
171
+ expect(typeof hooks.useApiMutation).toBe("function");
172
+ });
173
+
174
+ it("MUTATION : un module de peer sans hooks est refusé comme un peer absent", async () => {
175
+ const { queryClient } = recordingQueryClient();
176
+ let thrown: unknown;
177
+ try {
178
+ // The injected module is the peer's shape, so an empty one must be
179
+ // refused HERE — otherwise the failure would surface as "undefined is
180
+ // not a function" inside a render.
181
+ await createApiHooks(queryClient, { useQuery: undefined, useMutation: undefined });
182
+ } catch (failure) {
183
+ thrown = failure;
184
+ }
185
+ expect((thrown as OgygieFailure | undefined)?.error.code).toBe("CLIENT_REACT_PEER_MISSING");
186
+ });
187
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @intent React hooks over the manifest-derived client (D13, entry point
3
+ * `@ogygie/core/client-react`) — SEPARATE from `@ogygie/core/client` so the
4
+ * pure module stays consumable without React: a script, a test, another
5
+ * framework. A front feature no longer writes a `queries.ts`: the hook
6
+ * carries the derived cache key, the call, and the invalidations a successful
7
+ * mutation implies (own root + the roots the manifest declares).
8
+ *
9
+ * `react` and `@tanstack/react-query` are OPTIONAL peers of `@ogygie/core`
10
+ * (D1): the product that uses this module already owns them. They are loaded
11
+ * by DYNAMIC import, the doctrine already held for the studio and the MCP
12
+ * server (D7/D9) — which is exactly what makes the refusal STRUCTURED
13
+ * (CLIENT_REACT_PEER_MISSING, naming the install command) instead of a module
14
+ * resolution error nobody can act on. Hence an ASYNC factory. The peer may
15
+ * also be INJECTED (principle 5: a declared effect is passed in), which is
16
+ * what the tests do and what a product that prefers the explicit can do.
17
+ *
18
+ * This module imports NO react type: it declares the SHAPES it needs and
19
+ * nothing else — the framework repo does not install the peers, so a static
20
+ * import would make its own typecheck depend on a package it refuses to own.
21
+ */
22
+ import type { ApiOperation } from "./manifest-client.js";
23
+ import { throwOgygieError } from "./error.schema.js";
24
+
25
+ /** The install command a refusal names — one place, so it cannot drift. */
26
+ const PEER_INSTALL_COMMAND = "pnpm add react @tanstack/react-query";
27
+
28
+ /** What this module needs from a QueryClient: making derived keys stale. */
29
+ export interface QueryClientLike {
30
+ invalidateQueries: (filters: { queryKey: ReadonlyArray<unknown> }) => unknown;
31
+ }
32
+
33
+ export interface ApiQueryOptions {
34
+ queryKey: ReadonlyArray<unknown>;
35
+ queryFn: () => Promise<unknown>;
36
+ enabled?: boolean;
37
+ }
38
+
39
+ export interface ApiMutationOptions {
40
+ mutationKey: ReadonlyArray<unknown>;
41
+ mutationFn: (input?: Record<string, unknown>) => Promise<unknown>;
42
+ onSuccess: () => Promise<void>;
43
+ }
44
+
45
+ /** The two hooks of the peer this module builds upon — nothing else is used. */
46
+ export interface ReactQueryLike {
47
+ useQuery: (options: ApiQueryOptions) => unknown;
48
+ useMutation: (options: ApiMutationOptions) => unknown;
49
+ }
50
+
51
+ /**
52
+ * The SUBSET of a react-query result a screen composes with. The peer returns
53
+ * more; declaring what we consume keeps this module free of its types (and
54
+ * honest about what it promises).
55
+ */
56
+ export interface ApiQueryResult<TData> {
57
+ data: TData | undefined;
58
+ isPending: boolean;
59
+ isError: boolean;
60
+ error: unknown;
61
+ }
62
+
63
+ export interface ApiMutationResult<TData> {
64
+ mutate: (input?: Record<string, unknown>) => void;
65
+ mutateAsync: (input?: Record<string, unknown>) => Promise<TData>;
66
+ data: TData | undefined;
67
+ isPending: boolean;
68
+ isError: boolean;
69
+ error: unknown;
70
+ }
71
+
72
+ export interface ApiHooks {
73
+ /** Reads through the derived cache key — nothing to name, nothing to invent. */
74
+ useApiQuery: <TData = unknown>(
75
+ operation: ApiOperation,
76
+ input?: Record<string, unknown>,
77
+ options?: { enabled?: boolean },
78
+ ) => ApiQueryResult<TData>;
79
+ /** Writes, then makes stale what the manifest says this route makes stale. */
80
+ useApiMutation: <TData = unknown>(operation: ApiOperation) => ApiMutationResult<TData>;
81
+ }
82
+
83
+ /**
84
+ * The cache keys a successful mutation makes stale: its own root, plus every
85
+ * root the route declares in `invalidates`. DERIVED, never written in a
86
+ * screen — a forgotten invalidation never fails, it serves stale data.
87
+ */
88
+ export function staleQueryKeysOf(operation: ApiOperation): ReadonlyArray<ReadonlyArray<string>> {
89
+ return operation.invalidationRoots.map((root) => [root]);
90
+ }
91
+
92
+ /** Applies that plan. Exposed so a consumer outside React keeps the behavior. */
93
+ export async function invalidateAfterMutation(
94
+ queryClient: QueryClientLike,
95
+ operation: ApiOperation,
96
+ ): Promise<void> {
97
+ for (const queryKey of staleQueryKeysOf(operation)) {
98
+ await queryClient.invalidateQueries({ queryKey });
99
+ }
100
+ }
101
+
102
+ function looksLikeReactQuery(value: unknown): value is ReactQueryLike {
103
+ if (typeof value !== "object" || value === null) return false;
104
+ const candidate = value as Partial<ReactQueryLike>;
105
+ return typeof candidate.useQuery === "function" && typeof candidate.useMutation === "function";
106
+ }
107
+
108
+ /**
109
+ * The peer arrives from OUTSIDE the type system — a dynamic import, or the
110
+ * module a product hands over. It is therefore validated, never trusted: a
111
+ * module without the two hooks is refused HERE, where the refusal can name
112
+ * the fix, instead of surfacing as "undefined is not a function" in a render.
113
+ */
114
+ function asReactQuery(loaded: unknown, origin: string): ReactQueryLike {
115
+ if (!looksLikeReactQuery(loaded)) {
116
+ throwOgygieError(
117
+ {
118
+ code: "CLIENT_REACT_PEER_MISSING",
119
+ message: "@ogygie/core/client-react received no usable @tanstack/react-query module.",
120
+ file: null,
121
+ cause: `${origin}: the module carries neither useQuery nor useMutation as functions`,
122
+ suggestedFix: `Check the installed peer (${PEER_INSTALL_COMMAND}) — this module only uses useQuery and useMutation.`,
123
+ },
124
+ 500,
125
+ );
126
+ }
127
+ return loaded;
128
+ }
129
+
130
+ /**
131
+ * Loads the optional peer, or REFUSES with the command to run. The default
132
+ * importer keeps the specifier LITERAL so a bundler can see it (a computed one
133
+ * would break the browser build); it is a parameter because the load is an
134
+ * effect like any other (principle 5) — that is how the refusal is proven in a
135
+ * repo where the peer happens to be installed.
136
+ */
137
+ export async function loadReactQuery(
138
+ importPeer: () => Promise<unknown> = () => import("@tanstack/react-query"),
139
+ ): Promise<ReactQueryLike> {
140
+ let loaded: unknown;
141
+ try {
142
+ loaded = await importPeer();
143
+ } catch (thrown) {
144
+ throwOgygieError(
145
+ {
146
+ code: "CLIENT_REACT_PEER_MISSING",
147
+ message: "@ogygie/core/client-react needs its optional peer @tanstack/react-query.",
148
+ file: null,
149
+ cause: `dynamic import of "@tanstack/react-query" failed: ${String(thrown)}`,
150
+ suggestedFix: `Install the peers in the project that uses these hooks: ${PEER_INSTALL_COMMAND} — or pass the module to createApiHooks yourself.`,
151
+ },
152
+ 500,
153
+ );
154
+ }
155
+ return asReactQuery(loaded, "dynamic import of @tanstack/react-query");
156
+ }
157
+
158
+ /**
159
+ * THE way a product gets its hooks. `reactQuery` is optional: absent, the
160
+ * optional peer is loaded dynamically (and its absence refused structurally).
161
+ */
162
+ export async function createApiHooks(
163
+ queryClient: QueryClientLike,
164
+ reactQuery?: unknown,
165
+ ): Promise<ApiHooks> {
166
+ const hooks =
167
+ reactQuery === undefined
168
+ ? await loadReactQuery()
169
+ : asReactQuery(reactQuery, "module injected into createApiHooks");
170
+
171
+ return {
172
+ useApiQuery: <TData = unknown>(
173
+ operation: ApiOperation,
174
+ input?: Record<string, unknown>,
175
+ options?: { enabled?: boolean },
176
+ ): ApiQueryResult<TData> => {
177
+ const queryOptions: ApiQueryOptions = {
178
+ queryKey: operation.queryKey(input),
179
+ queryFn: () => operation(input),
180
+ ...(options?.enabled === undefined ? {} : { enabled: options.enabled }),
181
+ };
182
+ // The peer returns its own richer result; we promise only the subset we
183
+ // declared, and the response was already validated by the client.
184
+ return hooks.useQuery(queryOptions) as ApiQueryResult<TData>;
185
+ },
186
+
187
+ useApiMutation: <TData = unknown>(operation: ApiOperation): ApiMutationResult<TData> => {
188
+ const mutationOptions: ApiMutationOptions = {
189
+ mutationKey: [operation.namespace, operation.operation],
190
+ mutationFn: (input?: Record<string, unknown>) => operation(input),
191
+ onSuccess: () => invalidateAfterMutation(queryClient, operation),
192
+ };
193
+ return hooks.useMutation(mutationOptions) as ApiMutationResult<TData>;
194
+ },
195
+ };
196
+ }
@@ -1,19 +1,29 @@
1
1
  /**
2
- * @intent Proves the manifest-client derivation (spec front-derivation §1):
3
- * the client exposes exactly the declared routes (mutation test: a removed
4
- * route disappears), derives namespaces, operations, cache keys and
5
- * invalidation roots, and refuses loudly what it cannot derive. Fixtures are
6
- * deliberately vocabulary-neutral — the vocabulary test next door scans this
7
- * file too.
2
+ * @intent Proves the behaviors of specs/manifest-client.spec.md §1: the client
3
+ * exposes exactly the declared routes (mutation test: a removed route
4
+ * disappears), derives namespaces, operations, cache keys and invalidation
5
+ * roots, refuses loudly what it cannot derive, and speaks ONE error language
6
+ * on the wire (OgygieError untouched, everything else NETWORK_ERROR or
7
+ * CONTRACT_BROKEN). Fixtures are deliberately vocabulary-neutral — the
8
+ * vocabulary gate scans this file too.
8
9
  */
9
10
  import { describe, expect, it } from "vitest";
10
11
  import { z } from "zod";
11
- import { OgygieFailure } from "./error.schema.js";
12
+ import { OgygieFailure, ogygieError } from "./error.schema.js";
12
13
  import {
13
14
  createManifestClient,
14
- type ManifestClientRequest,
15
+ type ApiOperation,
16
+ type HttpResponseLike,
17
+ type ManifestClient,
15
18
  } from "./manifest-client.js";
16
19
 
20
+ /** Reads a derived operation, or fails the test — never a cast. */
21
+ function operationOf(client: ManifestClient, namespace: string, operation: string): ApiOperation {
22
+ const found = client[namespace]?.[operation];
23
+ if (found === undefined) throw new Error(`fixture client has no ${namespace}.${operation}`);
24
+ return found;
25
+ }
26
+
17
27
  const SCHEMA_NAMES = [
18
28
  "ListWidgetsInputSchema",
19
29
  "ListWidgetsOutputSchema",
@@ -22,7 +32,6 @@ const SCHEMA_NAMES = [
22
32
  "UpdateWidgetInputSchema",
23
33
  "UpdateWidgetOutputSchema",
24
34
  "WidgetParamsSchema",
25
- "GetWidgetOutputSchema",
26
35
  "ArchiveWidgetOutputSchema",
27
36
  "ListWorkOrdersInputSchema",
28
37
  "ListWorkOrdersOutputSchema",
@@ -34,9 +43,13 @@ const SCHEMA_NAMES = [
34
43
 
35
44
  /** One distinct schema instance per name, so identity can be asserted. */
36
45
  function schemaRegistry(): Record<string, z.ZodType> {
37
- return Object.fromEntries(
38
- SCHEMA_NAMES.map((name) => [name, z.record(z.string(), z.unknown())]),
39
- );
46
+ return {
47
+ ...Object.fromEntries(
48
+ SCHEMA_NAMES.map((name) => [name, z.record(z.string(), z.unknown())]),
49
+ ),
50
+ // One STRICT contract, so response validation has something to refuse.
51
+ GetWidgetOutputSchema: z.object({ widgetId: z.string() }),
52
+ };
40
53
  }
41
54
 
42
55
  interface RouteOverride {
@@ -143,14 +156,49 @@ function fixtureManifest(options?: {
143
156
  };
144
157
  }
145
158
 
146
- function buildClient(manifest: unknown = fixtureManifest()) {
147
- const requests: ManifestClientRequest[] = [];
148
- const schemas = schemaRegistry();
149
- const client = createManifestClient(manifest, schemas, {
150
- performRequest: async (request) => {
151
- requests.push(request);
152
- return { ok: true };
159
+ interface RecordedRequest {
160
+ url: string;
161
+ method: string;
162
+ headers: Record<string, string>;
163
+ body?: string;
164
+ }
165
+
166
+ /** A response the fake transport hands back, built like a real one would be. */
167
+ function response(status: number, rawBody: string): HttpResponseLike {
168
+ return { ok: status < 400, status, text: async () => rawBody };
169
+ }
170
+
171
+ function jsonResponse(status: number, body: unknown): HttpResponseLike {
172
+ return response(status, JSON.stringify(body));
173
+ }
174
+
175
+ function buildClient(options?: {
176
+ manifest?: unknown;
177
+ schemas?: Record<string, z.ZodType>;
178
+ validateResponses?: "always" | "never";
179
+ baseUrl?: string;
180
+ answer?: (request: RecordedRequest) => HttpResponseLike;
181
+ }): {
182
+ client: ManifestClient;
183
+ requests: RecordedRequest[];
184
+ schemas: Record<string, z.ZodType>;
185
+ } {
186
+ const requests: RecordedRequest[] = [];
187
+ const schemas = options?.schemas ?? schemaRegistry();
188
+ const answer = options?.answer ?? (() => jsonResponse(200, { ok: true }));
189
+ const client = createManifestClient(options?.manifest ?? fixtureManifest(), schemas, {
190
+ fetchImpl: async (url, init) => {
191
+ const recorded: RecordedRequest = {
192
+ url,
193
+ method: init.method,
194
+ headers: init.headers,
195
+ ...(init.body === undefined ? {} : { body: init.body }),
196
+ };
197
+ requests.push(recorded);
198
+ return answer(recorded);
153
199
  },
200
+ validateResponses: options?.validateResponses ?? "always",
201
+ ...(options?.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
154
202
  });
155
203
  return { client, requests, schemas };
156
204
  }
@@ -164,7 +212,17 @@ function failureCodeOf(run: () => unknown): string | null {
164
212
  }
165
213
  }
166
214
 
167
- describe("manifest-client — dérivation (spec front-derivation §1)", () => {
215
+ async function failureOf(run: () => Promise<unknown>): Promise<OgygieFailure> {
216
+ try {
217
+ await run();
218
+ } catch (thrown) {
219
+ if (thrown instanceof OgygieFailure) return thrown;
220
+ throw thrown;
221
+ }
222
+ throw new Error("the call was expected to fail, and it did not");
223
+ }
224
+
225
+ describe("manifest-client — dérivation (spec manifest-client §1)", () => {
168
226
  it("ÉTANT DONNÉ le manifeste QUAND le client est construit ALORS il expose exactement les routes déclarées", () => {
169
227
  const { client } = buildClient();
170
228
  expect(Object.keys(client).sort()).toEqual(["reports", "widgets", "workOrders"]);
@@ -180,11 +238,17 @@ describe("manifest-client — dérivation (spec front-derivation §1)", () => {
180
238
  });
181
239
 
182
240
  it("MUTATION : une route retirée du manifeste disparaît du client", () => {
183
- const { client } = buildClient(fixtureManifest({ dropArchiveRoute: true }));
241
+ const { client } = buildClient({ manifest: fixtureManifest({ dropArchiveRoute: true }) });
184
242
  expect(client["widgets"]?.["archive"]).toBeUndefined();
185
243
  expect(client["widgets"]?.["create"]).toBeDefined();
186
244
  });
187
245
 
246
+ it("chaque opération porte les schémas du registre, sans annotation manuelle", () => {
247
+ const { client, schemas } = buildClient();
248
+ expect(client["reports"]?.["search"]?.outputSchema).toBe(schemas["SearchReportsOutputSchema"]);
249
+ expect(client["reports"]?.["search"]?.inputSchema).toBe(schemas["SearchReportsInputSchema"]);
250
+ });
251
+
188
252
  it("les clés de cache sont dérivées du chemin : racine, opération, valeurs de params, puis reste de l'entrée", () => {
189
253
  const { client } = buildClient();
190
254
  expect(client["widgets"]?.["list"]?.queryKey()).toEqual(["widgets", "list"]);
@@ -203,7 +267,7 @@ describe("manifest-client — dérivation (spec front-derivation §1)", () => {
203
267
  expect(client["workOrders"]?.["list"]?.queryKey()).toEqual(["work-orders", "list"]);
204
268
  });
205
269
 
206
- it("RÉGRESSION (ecrans-plan) : deux GET voisins sur la même entité ne partagent JAMAIS une clé de cache", () => {
270
+ it("RÉGRESSION : deux GET voisins sur la même entité ne partagent JAMAIS une clé de cache", () => {
207
271
  // Without the operation in the key, widgets.get and a sibling
208
272
  // GET /api/widgets/:widgetId/child would collide — TanStack would dedupe
209
273
  // them and the second query would silently never fire.
@@ -224,64 +288,139 @@ describe("manifest-client — dérivation (spec front-derivation §1)", () => {
224
288
  expect(client["widgets"]?.["list"]?.isMutation).toBe(false);
225
289
  });
226
290
 
291
+ it("MUTATION : un schéma absent du registre refuse la construction", () => {
292
+ const schemas = schemaRegistry();
293
+ delete schemas["CreateWidgetInputSchema"];
294
+ expect(failureCodeOf(() => buildClient({ schemas }))).toBe("MANIFEST_CLIENT_UNKNOWN_SCHEMA");
295
+ });
296
+
297
+ it("MUTATION : deux routes qui dérivent la même opération refusent la construction", () => {
298
+ const manifest = fixtureManifest({
299
+ extraWidgetRoutes: [
300
+ {
301
+ method: "PUT",
302
+ path: "/api/widgets/:widgetId",
303
+ description: "Collides with PATCH: same derived operation name.",
304
+ schemaInput: "UpdateWidgetInputSchema",
305
+ schemaOutput: "UpdateWidgetOutputSchema",
306
+ },
307
+ ],
308
+ });
309
+ expect(failureCodeOf(() => buildClient({ manifest }))).toBe(
310
+ "MANIFEST_CLIENT_AMBIGUOUS_OPERATION",
311
+ );
312
+ });
313
+
314
+ it("MUTATION : un manifeste invalide refuse la construction au lieu de dériver un client menteur", () => {
315
+ expect(failureCodeOf(() => buildClient({ manifest: { version: 1 } }))).toBe(
316
+ "MANIFEST_CLIENT_INVALID_MANIFEST",
317
+ );
318
+ });
319
+ });
320
+
321
+ describe("manifest-client — transport (spec manifest-client §1)", () => {
227
322
  it("un appel GET envoie le reste de l'entrée en query string, sans body", async () => {
228
- const { client, requests, schemas } = buildClient();
323
+ const { client, requests } = buildClient({ validateResponses: "never" });
229
324
  await client["reports"]?.["search"]?.({ q: "ab", limit: 20 });
230
325
  expect(requests).toEqual([
231
326
  {
327
+ url: "/api/reports?q=ab&limit=20",
232
328
  method: "GET",
233
- path: "/api/reports?q=ab&limit=20",
234
- outputSchema: schemas["SearchReportsOutputSchema"],
329
+ headers: { accept: "application/json" },
235
330
  },
236
331
  ]);
237
332
  });
238
333
 
239
334
  it("un appel de mutation consomme les params du chemin et envoie le reste en body", async () => {
240
- const { client, requests } = buildClient();
335
+ const { client, requests } = buildClient({ validateResponses: "never" });
241
336
  await client["widgets"]?.["update"]?.({ widgetId: "w-1", title: "renamed" });
242
337
  await client["workOrders"]?.["accept"]?.({ orderId: "o-9" });
243
- expect(requests[0]?.path).toBe("/api/widgets/w-1");
244
- expect(requests[0]?.body).toEqual({ title: "renamed" });
245
- expect(requests[1]?.path).toBe("/api/work-orders/o-9/accept");
246
- expect(requests[1]?.body).toEqual({});
338
+ expect(requests[0]?.url).toBe("/api/widgets/w-1");
339
+ expect(requests[0]?.body).toBe(JSON.stringify({ title: "renamed" }));
340
+ expect(requests[0]?.headers["content-type"]).toBe("application/json");
341
+ expect(requests[1]?.url).toBe("/api/work-orders/o-9/accept");
342
+ expect(requests[1]?.body).toBe("{}");
247
343
  });
248
344
 
249
- it("un param de chemin manquant à l'appel est refusé bruyamment", async () => {
250
- const { client } = buildClient();
251
- await expect(client["widgets"]?.["update"]?.({ title: "renamed" })).rejects.toSatisfy(
252
- (thrown) =>
253
- thrown instanceof OgygieFailure && thrown.error.code === "CLIENT_MISSING_PATH_PARAM",
345
+ it("le baseUrl préfixe le chemin dérivé, sans jamais doubler la barre", async () => {
346
+ const { client, requests } = buildClient({
347
+ validateResponses: "never",
348
+ baseUrl: "https://api.example.test/",
349
+ });
350
+ await client["widgets"]?.["list"]?.();
351
+ expect(requests[0]?.url).toBe("https://api.example.test/api/widgets");
352
+ });
353
+
354
+ it("un param de chemin manquant à l'appel est refusé bruyamment, sans appeler le réseau", async () => {
355
+ const { client, requests } = buildClient();
356
+ const failure = await failureOf(() => operationOf(client, "widgets", "update")({ title: "x" }));
357
+ expect(failure.error.code).toBe("MANIFEST_CLIENT_MISSING_PATH_PARAM");
358
+ expect(requests).toEqual([]);
359
+ });
360
+
361
+ it("ÉTANT DONNÉ validateResponses \"always\" et une réponse violant le schéma ALORS CONTRACT_BROKEN", async () => {
362
+ const { client } = buildClient({
363
+ answer: () => jsonResponse(200, { widgetId: 42 }),
364
+ });
365
+ const failure = await failureOf(
366
+ () => operationOf(client, "widgets", "get")({ widgetId: "w-1" }),
254
367
  );
368
+ expect(failure.error.code).toBe("CONTRACT_BROKEN");
369
+ expect(failure.error.cause).toContain("GetWidgetOutputSchema");
255
370
  });
256
371
 
257
- it("MUTATION : un schéma absent du registre refuse la construction (CLIENT_MANIFEST_INVALID)", () => {
258
- const schemas = schemaRegistry();
259
- delete schemas["CreateWidgetInputSchema"];
260
- expect(
261
- failureCodeOf(() =>
262
- createManifestClient(fixtureManifest(), schemas, {
263
- performRequest: async () => ({}),
264
- }),
265
- ),
266
- ).toBe("CLIENT_MANIFEST_INVALID");
372
+ it("ÉTANT DONNÉ validateResponses \"never\" ALORS la même réponse passe telle quelle", async () => {
373
+ const { client } = buildClient({
374
+ validateResponses: "never",
375
+ answer: () => jsonResponse(200, { widgetId: 42 }),
376
+ });
377
+ await expect(client["widgets"]?.["get"]?.({ widgetId: "w-1" })).resolves.toEqual({
378
+ widgetId: 42,
379
+ });
267
380
  });
268
381
 
269
- it("MUTATION : deux routes qui dérivent la même opération refusent la construction", () => {
270
- const manifest = fixtureManifest({
271
- extraWidgetRoutes: [
272
- {
273
- method: "PUT",
274
- path: "/api/widgets/:widgetId",
275
- description: "Collides with PATCH: same derived operation name.",
276
- schemaInput: "UpdateWidgetInputSchema",
277
- schemaOutput: "UpdateWidgetOutputSchema",
278
- },
279
- ],
382
+ it("une réponse qui n'est pas du JSON est un contrat cassé, jamais une SyntaxError nue", async () => {
383
+ const { client } = buildClient({
384
+ answer: () => response(200, "<html>gateway timeout</html>"),
280
385
  });
281
- expect(failureCodeOf(() => buildClient(manifest))).toBe("CLIENT_MANIFEST_INVALID");
386
+ const failure = await failureOf(() => operationOf(client, "widgets", "list")());
387
+ expect(failure.error.code).toBe("CONTRACT_BROKEN");
282
388
  });
283
389
 
284
- it("MUTATION : un manifeste invalide refuse la construction au lieu de dériver un client menteur", () => {
285
- expect(failureCodeOf(() => buildClient({ version: 1 }))).toBe("CLIENT_MANIFEST_INVALID");
390
+ it("ÉTANT DONNÉ une réponse d'échec portant un corps OgygieError ALORS elle ressort inchangée", async () => {
391
+ const apiFailure = ogygieError({
392
+ code: "WIDGET_TITLE_ALREADY_USED",
393
+ message: "A widget with this title already exists in this account.",
394
+ file: "src/features/widgets/service.ts",
395
+ cause: "title=renamed already stored",
396
+ suggestedFix: "Choose another title, or update the existing widget.",
397
+ details: { conflictingId: "w-2" },
398
+ });
399
+ const { client } = buildClient({ answer: () => jsonResponse(409, apiFailure) });
400
+ const failure = await failureOf(
401
+ () => operationOf(client, "widgets", "update")({ widgetId: "w-1", title: "renamed" }),
402
+ );
403
+ expect(failure.error).toEqual(apiFailure);
404
+ expect(failure.httpStatus).toBe(409);
405
+ });
406
+
407
+ it("ÉTANT DONNÉ un échec sans corps OgygieError ALORS NETWORK_ERROR, statut conservé", async () => {
408
+ const { client } = buildClient({ answer: () => jsonResponse(502, { message: "boom" }) });
409
+ const failure = await failureOf(() => operationOf(client, "widgets", "list")());
410
+ expect(failure.error.code).toBe("NETWORK_ERROR");
411
+ expect(failure.error.details["status"]).toBe(502);
412
+ expect(failure.httpStatus).toBe(502);
413
+ });
414
+
415
+ it("ÉTANT DONNÉ un fetchImpl qui rejette ALORS NETWORK_ERROR, jamais l'erreur brute", async () => {
416
+ const client = createManifestClient(fixtureManifest(), schemaRegistry(), {
417
+ fetchImpl: async () => {
418
+ throw new TypeError("Failed to fetch");
419
+ },
420
+ validateResponses: "always",
421
+ });
422
+ const failure = await failureOf(() => operationOf(client, "widgets", "list")());
423
+ expect(failure.error.code).toBe("NETWORK_ERROR");
424
+ expect(failure.error.cause).toContain("Failed to fetch");
286
425
  });
287
426
  });
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * @intent GENERIC manifest-derived API client factory, framework side — the
3
3
  * browser pendant of the manifest-derived gateway: this module knows ZERO
4
- * product vocabulary (D10 amended — generic tooling lives in the core), and
5
- * the vocabulary test next to it proves it. It derives callable operations
6
- * from an Ogygie manifest: every declared route becomes a method under its
7
- * path-root namespace, nothing else exists — a route removed from the
8
- * manifest disappears from the client, the manifest is the only map (D27).
4
+ * product vocabulary (generic tooling lives in the core), and the vocabulary
5
+ * gate next to it proves it. It derives callable operations from an Ogygie
6
+ * manifest: every declared route becomes a method under its path-root
7
+ * namespace, nothing else exists — a route removed from the manifest
8
+ * disappears from the client, the manifest is the only map.
9
9
  *
10
10
  * Derivations (all from the manifest, nothing hand-written per feature):
11
11
  * - namespace: first path segment after /api, camelCased
@@ -15,20 +15,24 @@
15
15
  * declared input schema name ("SearchThingsInputSchema" → search); when the
16
16
  * schema name carries no verb, a method fallback (GET → list, GET on
17
17
  * ":param" → get, POST → create, PATCH/PUT → update, DELETE → delete);
18
- * - cache key: [root, ...path param values, remaining input if any] — the
19
- * number-one source of cache bugs, so it is never written by hand;
18
+ * - cache key: [root, operation, ...path param values, remaining input if any]
19
+ * — the number-one source of cache bugs, so it is never written by hand;
20
20
  * - invalidation roots: the route's own root, plus the roots the manifest
21
21
  * declares in `invalidates` (cross-feature staleness is READ, never
22
22
  * guessed in a screen).
23
23
  *
24
- * Transport stays OUTSIDE: the consumer injects `performRequest` (its typed
25
- * fetch over the shared contracts, dev-time response validation included) —
26
- * this module only resolves paths, splits inputs and derives keys. Building
27
- * an incoherent client REFUSES loudly (CLIENT_MANIFEST_INVALID): a refusal
24
+ * The TRANSPORT lives here too and owns no effect of its own: `fetchImpl` is
25
+ * injected (principle 5), no environment variable is ever read, and the
26
+ * response contract is re-validated when the product asks for it
27
+ * (`validateResponses: "always"` in dev). Failures speak ONE language: an
28
+ * OgygieError body comes back untouched, anything else is wrapped in
29
+ * NETWORK_ERROR or CONTRACT_BROKEN — never a raw fetch rejection.
30
+ *
31
+ * Building an incoherent client REFUSES loudly (MANIFEST_CLIENT_*): a refusal
28
32
  * at construction beats a method that lies at call time.
29
33
  */
30
34
  import { z } from "zod";
31
- import { throwOgygieError } from "./error.schema.js";
35
+ import { OgygieErrorSchema, OgygieFailure, throwOgygieError } from "./error.schema.js";
32
36
 
33
37
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
34
38
 
@@ -58,18 +62,36 @@ export const ClientManifestViewSchema = z.object({
58
62
  });
59
63
  export type ClientManifestView = z.infer<typeof ClientManifestViewSchema>;
60
64
 
61
- /** What the injected transport receives: a fully resolved request. */
62
- export interface ManifestClientRequest {
63
- method: HttpMethod;
64
- /** Path with params substituted and the query string already appended. */
65
- path: string;
66
- /** Present on POST/PUT/PATCH only: the input minus the path params. */
67
- body?: unknown;
68
- outputSchema: z.ZodType;
65
+ /** The SHAPE of a response the client needs — a `Response` satisfies it. */
66
+ export interface HttpResponseLike {
67
+ ok: boolean;
68
+ status: number;
69
+ text: () => Promise<string>;
69
70
  }
70
71
 
72
+ /**
73
+ * The injected network effect. `globalThis.fetch` satisfies it, and so does a
74
+ * fake in a test — this module never reaches the network by itself.
75
+ */
76
+ export type FetchLike = (
77
+ url: string,
78
+ init: { method: HttpMethod; headers: Record<string, string>; body?: string },
79
+ ) => Promise<HttpResponseLike>;
80
+
71
81
  export interface ManifestClientOptions {
72
- performRequest: (request: ManifestClientRequest) => Promise<unknown>;
82
+ fetchImpl: FetchLike;
83
+ /**
84
+ * "always" re-parses every response with its declared output schema (in dev
85
+ * a broken contract must fail HERE, not three screens later); "never" is a
86
+ * passthrough. The PRODUCT decides per environment — the core reads no env.
87
+ */
88
+ validateResponses: "always" | "never";
89
+ /**
90
+ * Prefix put in front of the derived "/api/..." path. Empty by default: the
91
+ * front calls its api RELATIVELY and knows neither host nor port
92
+ * (AGENTS.md §Sécurité 8). A trailing "/" is trimmed.
93
+ */
94
+ baseUrl?: string;
73
95
  }
74
96
 
75
97
  /** One derived operation: callable, and self-describing for the query layer. */
@@ -83,7 +105,7 @@ export interface ApiOperation {
83
105
  outputSchema: z.ZodType;
84
106
  /** True for POST/PUT/PATCH/DELETE — the caller picks query vs mutation. */
85
107
  isMutation: boolean;
86
- /** Derived cache key: [root, ...param values, remaining input if any]. */
108
+ /** Derived cache key: [root, operation, ...param values, remaining input if any]. */
87
109
  queryKey: (input?: Record<string, unknown>) => ReadonlyArray<unknown>;
88
110
  /** Cache roots stale after a successful mutation: own root + declared `invalidates`. */
89
111
  invalidationRoots: ReadonlyArray<string>;
@@ -107,15 +129,28 @@ function verbOfInputSchemaName(schemaName: string): string | null {
107
129
  return match?.[1] === undefined ? null : match[1].toLowerCase();
108
130
  }
109
131
 
110
- function refuseClient(cause: string): never {
132
+ /**
133
+ * Construction refusals, one STABLE code per cause: "the manifest is
134
+ * unreadable", "a declared schema is missing from the registry" and "two
135
+ * routes derive the same operation" are three different things to fix, and an
136
+ * agent reading the error alone must know which.
137
+ */
138
+ function refuseClient(
139
+ code:
140
+ | "MANIFEST_CLIENT_INVALID_MANIFEST"
141
+ | "MANIFEST_CLIENT_UNKNOWN_SCHEMA"
142
+ | "MANIFEST_CLIENT_AMBIGUOUS_OPERATION",
143
+ cause: string,
144
+ suggestedFix: string,
145
+ ): never {
111
146
  throwOgygieError(
112
147
  {
113
- code: "CLIENT_MANIFEST_INVALID",
114
- message: "Manifest client construction refused: the manifest cannot be derived into operations.",
148
+ code,
149
+ message:
150
+ "Manifest client construction refused: the manifest cannot be derived into operations.",
115
151
  file: "ogygie.manifest.json",
116
152
  cause,
117
- suggestedFix:
118
- "Fix the manifest (or register the missing schema in the client's schema registry) — a construction refusal beats a method that lies.",
153
+ suggestedFix,
119
154
  },
120
155
  500,
121
156
  );
@@ -163,9 +198,11 @@ export function createManifestClient(
163
198
  const parsedManifest = ClientManifestViewSchema.safeParse(manifest);
164
199
  if (!parsedManifest.success) {
165
200
  refuseClient(
201
+ "MANIFEST_CLIENT_INVALID_MANIFEST",
166
202
  parsedManifest.error.issues
167
203
  .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
168
204
  .join(" ; "),
205
+ "Fix the declared route surface (method, /api path, schemaInput, schemaOutput) — a construction refusal beats a method that lies.",
169
206
  );
170
207
  }
171
208
 
@@ -173,24 +210,40 @@ export function createManifestClient(
173
210
  const mounted = new Set<string>();
174
211
  for (const feature of parsedManifest.data.features) {
175
212
  for (const route of feature.routes) {
213
+ const key = `${route.method} ${route.path}`;
176
214
  if (!WELL_FORMED_ROUTE_PATH.test(route.path)) {
177
- refuseClient(`feature "${feature.name}": route path "${route.path}" is malformed`);
215
+ refuseClient(
216
+ "MANIFEST_CLIENT_INVALID_MANIFEST",
217
+ `feature "${feature.name}": route path "${route.path}" is malformed`,
218
+ 'Write the path as static or ":param" segments under /api (e.g. "/api/orders/:orderId").',
219
+ );
178
220
  }
179
221
  for (const schemaName of [route.schemaInput, route.schemaOutput]) {
180
222
  if (schemas[schemaName] === undefined) {
181
223
  refuseClient(
182
- `feature "${feature.name}": route ${route.method} ${route.path} declares schema "${schemaName}" which is not in the schema registry`,
224
+ "MANIFEST_CLIENT_UNKNOWN_SCHEMA",
225
+ `feature "${feature.name}": route ${key} declares schema "${schemaName}", absent from the schema registry`,
226
+ `Register "${schemaName}" in the registry passed to createManifestClient, or fix the name declared in the manifest.`,
183
227
  );
184
228
  }
185
229
  }
186
- const key = `${route.method} ${route.path}`;
187
- if (mounted.has(key)) refuseClient(`route ${key} is declared twice`);
230
+ if (mounted.has(key)) {
231
+ refuseClient(
232
+ "MANIFEST_CLIENT_INVALID_MANIFEST",
233
+ `route ${key} is declared twice`,
234
+ "Declare each method+path exactly once in the manifest.",
235
+ );
236
+ }
188
237
  mounted.add(key);
189
238
 
190
239
  const segments = route.path.split("/").slice(2);
191
240
  const root = segments[0];
192
241
  if (root === undefined || root.startsWith(":")) {
193
- refuseClient(`route ${key}: the first segment after /api must be static`);
242
+ refuseClient(
243
+ "MANIFEST_CLIENT_INVALID_MANIFEST",
244
+ `route ${key}: the first segment after /api must be static`,
245
+ 'Start the path with a static root — it is both the namespace and the cache root ("/api/orders/...").',
246
+ );
194
247
  }
195
248
  const entry: DerivedRoute = {
196
249
  method: route.method,
@@ -215,7 +268,9 @@ export function createManifestClient(
215
268
  const namespaceOperations = (client[route.namespace] ??= {});
216
269
  if (namespaceOperations[route.operation] !== undefined) {
217
270
  refuseClient(
218
- `operation "${route.namespace}.${route.operation}" is derived twice (${route.method} ${route.path} collides) — the derivation must stay unambiguous`,
271
+ "MANIFEST_CLIENT_AMBIGUOUS_OPERATION",
272
+ `operation "${route.namespace}.${route.operation}" is derived twice (${route.method} ${route.path} collides)`,
273
+ "Give one of the two routes a distinct static segment (or a distinct input schema verb) — a silent overwrite would hide a route.",
219
274
  );
220
275
  }
221
276
  namespaceOperations[route.operation] = buildOperation(route, schemas, options);
@@ -223,6 +278,26 @@ export function createManifestClient(
223
278
  return client;
224
279
  }
225
280
 
281
+ /** JSON body of a response, or a CONTRACT_BROKEN refusal — never a raw SyntaxError. */
282
+ function parseJsonBody(rawBody: string, route: DerivedRoute, url: string): unknown {
283
+ if (rawBody.trim() === "") return undefined;
284
+ try {
285
+ return JSON.parse(rawBody) as unknown;
286
+ } catch (thrown) {
287
+ throwOgygieError(
288
+ {
289
+ code: "CONTRACT_BROKEN",
290
+ message: `Response of ${route.namespace}.${route.operation} is not JSON.`,
291
+ file: null,
292
+ cause: `${route.method} ${url} answered a body starting with ${JSON.stringify(rawBody.slice(0, 80))} (${String(thrown)})`,
293
+ suggestedFix:
294
+ "Find what really answered on this path (a proxy error page? an html fallback?) — the declared output schema promises JSON.",
295
+ },
296
+ 500,
297
+ );
298
+ }
299
+ }
300
+
226
301
  function buildOperation(
227
302
  route: DerivedRoute,
228
303
  schemas: Record<string, z.ZodType>,
@@ -231,8 +306,14 @@ function buildOperation(
231
306
  const inputSchema = schemas[route.schemaInput];
232
307
  const outputSchema = schemas[route.schemaOutput];
233
308
  if (inputSchema === undefined || outputSchema === undefined) {
234
- refuseClient(`route ${route.method} ${route.path}: schema lookup failed`); // unreachable — checked above
309
+ // Unreachable: createManifestClient refused every unknown schema above.
310
+ refuseClient(
311
+ "MANIFEST_CLIENT_UNKNOWN_SCHEMA",
312
+ `route ${route.method} ${route.path}: schema lookup failed`,
313
+ "Register the declared input and output schemas before building the client.",
314
+ );
235
315
  }
316
+ const baseUrl = (options.baseUrl ?? "").replace(/\/$/, "");
236
317
 
237
318
  /** Path params consumed by name; everything else is body or query string. */
238
319
  function splitInput(input: Record<string, unknown>): {
@@ -246,7 +327,7 @@ function buildOperation(
246
327
  if (typeof value !== "string" || value === "") {
247
328
  throwOgygieError(
248
329
  {
249
- code: "CLIENT_MISSING_PATH_PARAM",
330
+ code: "MANIFEST_CLIENT_MISSING_PATH_PARAM",
250
331
  message: `Operation ${route.namespace}.${route.operation} was called without its ":${paramName}" path param.`,
251
332
  file: null,
252
333
  cause: `${route.method} ${route.path} needs "${paramName}", received ${JSON.stringify(value)}`,
@@ -270,24 +351,83 @@ function buildOperation(
270
351
  encodeURIComponent(String(input[paramName])),
271
352
  );
272
353
  }
273
- if (METHODS_WITH_BODY.has(route.method)) {
274
- return options.performRequest({
354
+
355
+ const carriesBody = METHODS_WITH_BODY.has(route.method);
356
+ let url = `${baseUrl}${resolvedPath}`;
357
+ if (!carriesBody) {
358
+ const searchParameters = new URLSearchParams();
359
+ for (const [name, value] of Object.entries(rest)) {
360
+ if (value !== undefined && value !== null) searchParameters.set(name, String(value));
361
+ }
362
+ const queryString = searchParameters.toString();
363
+ if (queryString !== "") url = `${url}?${queryString}`;
364
+ }
365
+
366
+ let response: HttpResponseLike;
367
+ let rawBody: string;
368
+ try {
369
+ response = await options.fetchImpl(url, {
275
370
  method: route.method,
276
- path: resolvedPath,
277
- body: rest,
278
- outputSchema,
371
+ headers: carriesBody
372
+ ? { "content-type": "application/json", accept: "application/json" }
373
+ : { accept: "application/json" },
374
+ ...(carriesBody ? { body: JSON.stringify(rest) } : {}),
279
375
  });
376
+ rawBody = await response.text();
377
+ } catch (thrown) {
378
+ // The network layer failed BEFORE anything could be judged: nothing was
379
+ // answered, so nothing can be blamed on a contract.
380
+ throwOgygieError(
381
+ {
382
+ code: "NETWORK_ERROR",
383
+ message: `Call to ${route.namespace}.${route.operation} never reached the api.`,
384
+ file: null,
385
+ cause: `${route.method} ${url} threw ${String(thrown)}`,
386
+ suggestedFix:
387
+ "Check that the api answers on this path (dev proxy up? offline?) — the client never invents a fallback answer.",
388
+ },
389
+ 503,
390
+ );
280
391
  }
281
- const searchParameters = new URLSearchParams();
282
- for (const [name, value] of Object.entries(rest)) {
283
- if (value !== undefined && value !== null) searchParameters.set(name, String(value));
392
+
393
+ if (!response.ok) {
394
+ // An OgygieError body is the api SPEAKING: it comes back untouched —
395
+ // code, details and suggestedFix included. Wrapping it would destroy
396
+ // the only thing a screen can act on.
397
+ const failurePayload = OgygieErrorSchema.safeParse(parseJsonBody(rawBody, route, url));
398
+ if (failurePayload.success) throw new OgygieFailure(failurePayload.data, response.status);
399
+ throwOgygieError(
400
+ {
401
+ code: "NETWORK_ERROR",
402
+ message: `Call to ${route.namespace}.${route.operation} failed with status ${response.status}.`,
403
+ file: null,
404
+ cause: `${route.method} ${url} answered ${response.status} without an OgygieError body: ${JSON.stringify(rawBody.slice(0, 200))}`,
405
+ suggestedFix:
406
+ "Find what answered (gateway? proxy? an api without its error handler?) — every Ogygie failure carries an OgygieError body.",
407
+ details: { status: response.status },
408
+ },
409
+ response.status,
410
+ );
411
+ }
412
+
413
+ const data = parseJsonBody(rawBody, route, url);
414
+ if (options.validateResponses === "never") return data;
415
+ const validated = outputSchema.safeParse(data);
416
+ if (!validated.success) {
417
+ throwOgygieError(
418
+ {
419
+ code: "CONTRACT_BROKEN",
420
+ message: `Response of ${route.namespace}.${route.operation} violates its declared output schema.`,
421
+ file: null,
422
+ cause: `${route.method} ${url} — ${route.schemaOutput}: ${validated.error.issues
423
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
424
+ .join(" ; ")}`,
425
+ suggestedFix: `Align the api answer with ${route.schemaOutput}, or amend the contract on both sides in the same PR.`,
426
+ },
427
+ 500,
428
+ );
284
429
  }
285
- const queryString = searchParameters.toString();
286
- return options.performRequest({
287
- method: route.method,
288
- path: queryString === "" ? resolvedPath : `${resolvedPath}?${queryString}`,
289
- outputSchema,
290
- });
430
+ return validated.data;
291
431
  };
292
432
 
293
433
  return Object.assign(call, {
@@ -303,7 +443,7 @@ function buildOperation(
303
443
  // The OPERATION is part of the key: two GET routes on the same entity
304
444
  // (…/:id, …/:id/quantities, …/:id/costs) must never share a cache entry
305
445
  // — without it TanStack dedupes them and the later queries never fire
306
- // (found by ecrans-plan, the first consumer of sibling GET routes).
446
+ // (regression found by the first consumer of sibling GET routes).
307
447
  const key: unknown[] = [route.root, route.operation, ...paramValues];
308
448
  if (Object.keys(rest).length > 0) key.push(rest);
309
449
  return key;
@@ -73,7 +73,7 @@ describe("manifest — features front", () => {
73
73
  });
74
74
  });
75
75
 
76
- // Front-derivation extension (spec front-derivation): a mutation route may
76
+ // Manifest-client extension (specs/manifest-client.spec.md §3): a mutation route may
77
77
  // declare the cache roots it stales beyond its own — read by the derived
78
78
  // client, verified here: a target matching nothing is a typo that would
79
79
  // silently never invalidate anything.
@@ -102,7 +102,7 @@ const listArchiveRoute = {
102
102
  schemaOutput: "ListRecordArchiveOutputSchema",
103
103
  };
104
104
 
105
- describe("manifest — invalidations croisées des routes (spec front-derivation)", () => {
105
+ describe("manifest — invalidations croisées des routes (spec manifest-client §3)", () => {
106
106
  it("une route sans invalidates reste acceptée (champ optionnel, élargissement libre)", () => {
107
107
  const parsed = OgygieManifestSchema.safeParse(
108
108
  manifestWithApiRoutes([createRecordRoute]),