@fougere/app 0.6.0-alpha.0 → 0.7.0-alpha.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/dist/auth.d.ts +1 -7
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/boot.d.ts +14 -85
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +17 -77
- package/dist/boot.js.map +1 -1
- package/dist/client.d.ts +5 -24
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +6 -32
- package/dist/client.js.map +1 -1
- package/dist/express.d.ts +2 -12
- package/dist/express.d.ts.map +1 -1
- package/dist/express.js +4 -52
- package/dist/express.js.map +1 -1
- package/dist/form.d.ts +11 -56
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +15 -33
- package/dist/form.js.map +1 -1
- package/dist/graphql.d.ts +2 -28
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +1 -6
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +1 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -11
- package/dist/index.js.map +1 -1
- package/dist/rest.d.ts +2 -16
- package/dist/rest.d.ts.map +1 -1
- package/dist/rest.js +4 -34
- package/dist/rest.js.map +1 -1
- package/dist/serve.d.ts +10 -57
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +12 -55
- package/dist/serve.js.map +1 -1
- package/dist/session.d.ts +2 -7
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +2 -7
- package/dist/session.js.map +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +1 -11
- package/dist/state.js.map +1 -1
- package/dist/web.d.ts +3 -19
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +4 -32
- package/dist/web.js.map +1 -1
- package/package.json +11 -11
- package/src/auth.ts +1 -7
- package/src/boot.ts +28 -134
- package/src/client.ts +7 -33
- package/src/express.ts +4 -52
- package/src/form.ts +20 -73
- package/src/graphql.ts +2 -28
- package/src/index.ts +1 -11
- package/src/rest.ts +4 -34
- package/src/serve.ts +13 -60
- package/src/session.ts +2 -7
- package/src/state.ts +1 -11
- package/src/web.ts +4 -32
package/src/client.ts
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The couple, minus the reactivity — everything `useQuery`/`useCommand` decide
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Designation is class + verb: the imported entity class carries the metadata,
|
|
6
|
-
* its name carries the registration key. That is true in Vue and in React, and so
|
|
7
|
-
* is the link — a successful command on an entity revalidates every mounted query
|
|
8
|
-
* on that entity, because the entity is designated on both sides and nothing has
|
|
9
|
-
* to be declared. What differs between hosts is only HOW a value becomes reactive
|
|
10
|
-
* and how a revalidation is triggered, which is ~50 lines each and belongs to them.
|
|
11
|
-
*
|
|
12
|
-
* Browser-safe by construction: this module reaches `@fougere/core/contract` and
|
|
13
|
-
* the transport's client subpath, never the boot. `@fougere/app/client` is the
|
|
14
|
-
* subpath that keeps it that way.
|
|
2
|
+
* The couple, minus the reactivity — everything `useQuery`/`useCommand` decide before a
|
|
3
|
+
* framework's state primitives get involved.
|
|
15
4
|
*/
|
|
16
5
|
import {
|
|
17
6
|
FougereError,
|
|
@@ -26,7 +15,7 @@ import { frameCall, unframeResponse, type RpcResponse } from '@fougere/transport
|
|
|
26
15
|
export type EntityClass = { name: string };
|
|
27
16
|
|
|
28
17
|
/** What a page provides of an invocation — the rest is stamped server-side. */
|
|
29
|
-
export type CallInput = Partial<Pick<InvocationContext, 'params' | 'query' | '
|
|
18
|
+
export type CallInput = Partial<Pick<InvocationContext, 'params' | 'query' | 'input'>>;
|
|
30
19
|
|
|
31
20
|
/** The one door the browser knows. A named surface adds `/{surface}` to it. */
|
|
32
21
|
export const CALL_ENDPOINT = '/_fougere/call';
|
|
@@ -45,14 +34,10 @@ export function callOf(entity: EntityClass, op: string): FrondCall {
|
|
|
45
34
|
}
|
|
46
35
|
|
|
47
36
|
export function invocationOf(input?: CallInput): InvocationContext {
|
|
48
|
-
return { params: {}, query: {},
|
|
37
|
+
return { params: {}, query: {}, input: undefined, state: {}, ...input };
|
|
49
38
|
}
|
|
50
39
|
|
|
51
|
-
/**
|
|
52
|
-
* The cache key of a read. Same designation and same input means the same key —
|
|
53
|
-
* which is what lets two components asking the same thing share one request, and
|
|
54
|
-
* what the command side matches against to revalidate.
|
|
55
|
-
*/
|
|
40
|
+
/** The cache key of a read. */
|
|
56
41
|
export function queryKeyOf(entityKey: string, op: string, input?: CallInput): string {
|
|
57
42
|
return `fougere:${entityKey}.${op}:${JSON.stringify(input ?? {})}`;
|
|
58
43
|
}
|
|
@@ -88,14 +73,7 @@ export function mountedKeys(entityKey: string): string[] {
|
|
|
88
73
|
return [...(mounted.get(entityKey) ?? [])];
|
|
89
74
|
}
|
|
90
75
|
|
|
91
|
-
/**
|
|
92
|
-
* `mountedKeys` says WHICH reads a command invalidates; these say how to make one
|
|
93
|
-
* happen. Both halves turned out to be host-independent — Nuxt is the exception,
|
|
94
|
-
* because `refreshNuxtData` already is this registry.
|
|
95
|
-
*
|
|
96
|
-
* They lived in `@fougere/react` until a second non-Nuxt client needed them, which
|
|
97
|
-
* is when it became visible that nothing in them is React.
|
|
98
|
-
*/
|
|
76
|
+
/** `mountedKeys` says WHICH reads a command invalidates; these say how to make one happen. */
|
|
99
77
|
const refetchers = new Map<string, Set<() => void>>();
|
|
100
78
|
|
|
101
79
|
/** Register a mounted read's refetch. Returns the unregistration. */
|
|
@@ -135,11 +113,7 @@ export function pageOf(data: unknown): { total?: number; hasMore?: boolean; endC
|
|
|
135
113
|
return (data ?? {}) as { total?: number; hasMore?: boolean; endCursor?: string };
|
|
136
114
|
}
|
|
137
115
|
|
|
138
|
-
/**
|
|
139
|
-
* Whatever failed, as the error the primitives promise. A transport failure is not
|
|
140
|
-
* a domain refusal, so it arrives under SERVICE_UNAVAILABLE rather than borrowing a
|
|
141
|
-
* code the server never sent.
|
|
142
|
-
*/
|
|
116
|
+
/** Whatever failed, as the error the primitives promise. */
|
|
143
117
|
export function asFougereError(err: unknown, entityKey: string, op: string): FougereError {
|
|
144
118
|
return err instanceof FougereError
|
|
145
119
|
? err
|
package/src/express.ts
CHANGED
|
@@ -1,35 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The doors as Express middlewares — the form an Express app expects.
|
|
3
|
-
*
|
|
4
|
-
* ```ts
|
|
5
|
-
* const app = express();
|
|
6
|
-
* app.use(express.json());
|
|
7
|
-
* app.use(fougere()); // ← one line, like cors() or express.json()
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* This replaces an earlier `mountDoors(createExpressRouter(app))`, which was wrong in
|
|
11
|
-
* two ways at once. It handed the app INTO a function instead of adding something to
|
|
12
|
-
* the app, and it made the caller name Express 5's wildcard syntax (`/api/*splat`) —
|
|
13
|
-
* a detail that belongs to the framework, not to its user. Worse, it mounted all
|
|
14
|
-
* three doors together with no way to take two of them.
|
|
15
|
-
*
|
|
16
|
-
* A middleware fixes both by construction, because Express already says WHERE:
|
|
17
|
-
*
|
|
18
|
-
* ```ts
|
|
19
|
-
* app.use(fougere()); // the three doors, at the paths the client knows
|
|
20
|
-
* app.use('/admin', fougereRest()); // REST only, wherever you want it
|
|
21
|
-
* ```
|
|
22
|
-
*
|
|
23
|
-
* And `next()` is the passthrough these doors were already imitating: `serveRest`
|
|
24
|
-
* answers `{ kind: 'pass' }` for a path it does not serve, which is exactly what
|
|
25
|
-
* Express means by calling the next handler. The middleware stops inventing it.
|
|
26
|
-
*
|
|
27
|
-
* The names mirror `@fougere/app/web` on purpose — same doors, host-shaped. `/web`
|
|
28
|
-
* gives you `Request` → `Response` handlers; this gives you middlewares.
|
|
29
|
-
*
|
|
30
|
-
* Nothing here imports express: the shapes are structural, so the package keeps its
|
|
31
|
-
* dependency list and a test can hand it a plain object.
|
|
32
|
-
*/
|
|
1
|
+
/** The doors as Express middlewares — the form an Express app expects. */
|
|
33
2
|
import { readExpressBody } from '@fougere/http';
|
|
34
3
|
import { serveRest, serveRpc, rpcParseError } from './serve.js';
|
|
35
4
|
import { serveGraphQL } from './graphql.js';
|
|
@@ -59,14 +28,7 @@ interface ExpressResponse {
|
|
|
59
28
|
type Next = (err?: unknown) => void;
|
|
60
29
|
export type ExpressMiddleware = (req: any, res: any, next: Next) => void;
|
|
61
30
|
|
|
62
|
-
/**
|
|
63
|
-
* Who the caller is, from what ran before.
|
|
64
|
-
*
|
|
65
|
-
* Express has no ambient request, so an app that resolves its own session says so by
|
|
66
|
-
* putting it on the request — `req.fougereState`, or a bare `req.user`, which is what
|
|
67
|
-
* passport and most middlewares already set. Nothing is taken from the payload: the
|
|
68
|
-
* browser sits outside the topology.
|
|
69
|
-
*/
|
|
31
|
+
/** Who the caller is, from what ran before. */
|
|
70
32
|
function stateOf(req: ExpressRequest): Record<string, unknown> {
|
|
71
33
|
if (req.fougereState) return req.fougereState;
|
|
72
34
|
return req.user ? { user: req.user } : {};
|
|
@@ -95,12 +57,7 @@ function fail(res: ExpressResponse, next: Next, err: unknown): void {
|
|
|
95
57
|
next(err);
|
|
96
58
|
}
|
|
97
59
|
|
|
98
|
-
/**
|
|
99
|
-
* The call envelope, at `/_fougere/call` — the door the browser primitives use.
|
|
100
|
-
*
|
|
101
|
-
* Mounted with `app.use()`, Express strips nothing, so the path still carries the
|
|
102
|
-
* audience segment (`/_fougere/call/public`) that `surfaceOf` reads.
|
|
103
|
-
*/
|
|
60
|
+
/** The call envelope, at `/_fougere/call` — the door the browser primitives use. */
|
|
104
61
|
export function fougereCall(mountPath = '/_fougere/call'): ExpressMiddleware {
|
|
105
62
|
return (req, res, next) => {
|
|
106
63
|
const path = pathOf(req);
|
|
@@ -132,12 +89,7 @@ export function fougereSession(mountPath = '/_fougere/session'): ExpressMiddlewa
|
|
|
132
89
|
};
|
|
133
90
|
}
|
|
134
91
|
|
|
135
|
-
/**
|
|
136
|
-
* The REST projection, under `/api` by default.
|
|
137
|
-
*
|
|
138
|
-
* A path this app does not serve calls `next()` — so an app's own `/api/health` keeps
|
|
139
|
-
* answering whether it was registered before or after this middleware.
|
|
140
|
-
*/
|
|
92
|
+
/** The REST projection, under `/api` by default. */
|
|
141
93
|
export function fougereRest(mountPath = '/api'): ExpressMiddleware {
|
|
142
94
|
return (req, res, next) => {
|
|
143
95
|
const path = pathOf(req);
|
package/src/form.ts
CHANGED
|
@@ -1,30 +1,15 @@
|
|
|
1
1
|
import { Lifecycle } from '@fougere/schema';
|
|
2
2
|
/**
|
|
3
|
-
* Form contract, pure part — derives what a create/edit form is made of
|
|
4
|
-
*
|
|
5
|
-
* usable by any renderer (the page owns the widgets).
|
|
3
|
+
* Form contract, pure part — derives what a create/edit form is made of from the entity's field
|
|
4
|
+
* axes.
|
|
6
5
|
*/
|
|
7
|
-
import {
|
|
6
|
+
import { Shapes, lowerFirst, Role, Visibility } from '@fougere/schema';
|
|
8
7
|
import type { Field, SchemaView, ValidationError, ValidationResult } from '@fougere/schema';
|
|
9
8
|
|
|
10
|
-
/**
|
|
11
|
-
* What an entity class exposes to a form — the schema statics it already has.
|
|
12
|
-
*
|
|
13
|
-
* `SchemaView` and the real `Field`, not a local re-description of the axes: this file
|
|
14
|
-
* used to declare its own `FieldLike` with `shape?` optional, so it kept judging by a
|
|
15
|
-
* looser contract than the schema's own and would never have seen `shape` become
|
|
16
|
-
* required.
|
|
17
|
-
*/
|
|
9
|
+
/** What an entity class exposes to a form — the schema statics it already has. */
|
|
18
10
|
export type FormEntity = SchemaView;
|
|
19
11
|
|
|
20
|
-
/**
|
|
21
|
-
* The literal a field is born with, when it declares one.
|
|
22
|
-
*
|
|
23
|
-
* `text({ default: 'x' })` and `oneOf('a', 'b', { default: 'a' })` both compile to
|
|
24
|
-
* `lifecycle.create = { value }` — the create rule that answers the field's absence.
|
|
25
|
-
* The other create rules ('now', { generate }, 'optional') name no literal: their value
|
|
26
|
-
* is decided at write time, so a form has nothing to show for them.
|
|
27
|
-
*/
|
|
12
|
+
/** The literal a field is born with, when it declares one. */
|
|
28
13
|
function defaultOf(field: Field): unknown {
|
|
29
14
|
return Lifecycle.of(field).literal?.value;
|
|
30
15
|
}
|
|
@@ -41,28 +26,8 @@ export interface FormField {
|
|
|
41
26
|
/** Enum values, when control is 'select'. */
|
|
42
27
|
options?: string[];
|
|
43
28
|
/**
|
|
44
|
-
* What the browser enforces, under the names it already knows — spread this on the
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* The shape holds `minLength`/`maximum`/`pattern`; a browser holds `minlength`/
|
|
48
|
-
* `max`/`pattern` and enforces them with no JavaScript at all. Carrying them here
|
|
49
|
-
* is a projection, not a second rule: the judge reads the same shape, and a form
|
|
50
|
-
* that ignores these still gets the same verdict — it just gets it later, and a
|
|
51
|
-
* screen reader never gets it at all.
|
|
52
|
-
*
|
|
53
|
-
* `type` is part of the contract, not decoration: `email` and `url` are formats the
|
|
54
|
-
* shape states and the browser checks live, per field, as one types. A page writing
|
|
55
|
-
* `type="email"` by hand is spelling a second time what the card already said.
|
|
56
|
-
*
|
|
57
|
-
* Three deliberate absences, each one a place where the attribute would mean
|
|
58
|
-
* something the shape does not say:
|
|
59
|
-
* - a `date` field gets no `type` — neither `date` nor `datetime-local` produces the
|
|
60
|
-
* RFC 3339 string a `date-time` shape judges, so the browser would accept what the
|
|
61
|
-
* judge refuses;
|
|
62
|
-
* - `select` and `boolean` are not inputs — the page picks the widget, `control` says
|
|
63
|
-
* which;
|
|
64
|
-
* - a required `boolean` gets no `required` — on a checkbox that attribute means
|
|
65
|
-
* "must be CHECKED", where the shape only says the value must be supplied.
|
|
29
|
+
* What the browser enforces, under the names it already knows — spread this on the input and the
|
|
30
|
+
* page states no rule of its own.
|
|
66
31
|
*/
|
|
67
32
|
attrs?: {
|
|
68
33
|
type?: 'text' | 'email' | 'url' | 'number';
|
|
@@ -73,11 +38,7 @@ export interface FormField {
|
|
|
73
38
|
max?: number;
|
|
74
39
|
pattern?: string;
|
|
75
40
|
};
|
|
76
|
-
/**
|
|
77
|
-
* The value the field is born with — the literal its `lifecycle.create` rule names.
|
|
78
|
-
* Present so the form can SHOW what is about to be written; the storage realizes it
|
|
79
|
-
* either way, so a form that ignores this still produces the same row.
|
|
80
|
-
*/
|
|
41
|
+
/** The value the field is born with — the literal its `lifecycle.create` rule names. */
|
|
81
42
|
default?: unknown;
|
|
82
43
|
}
|
|
83
44
|
|
|
@@ -87,7 +48,7 @@ function baseType(type: unknown): string {
|
|
|
87
48
|
return (type as string) ?? 'string';
|
|
88
49
|
}
|
|
89
50
|
|
|
90
|
-
/** The formats a browser has an input type for — the rest stay `text`,
|
|
51
|
+
/** The formats a browser has an input type for — the rest stay `text`, validated later. */
|
|
91
52
|
const CONTROL_BY_FORMAT: Record<string, FormField['control']> = {
|
|
92
53
|
'date-time': 'date',
|
|
93
54
|
email: 'email',
|
|
@@ -98,7 +59,7 @@ function controlOf(field: Field): FormField['control'] {
|
|
|
98
59
|
// Through `anatomy`, never `shape.type` directly: the nullable form is the `[T,'null']`
|
|
99
60
|
// union, which a direct comparison misses in silence. It is also what narrows the shape
|
|
100
61
|
// union, so `enum` and `format` are only reachable on the branches that carry them.
|
|
101
|
-
const base =
|
|
62
|
+
const base = Shapes.of(field.shape).base;
|
|
102
63
|
if (base?.type === 'string' && base.enum?.length) return 'select';
|
|
103
64
|
if (base?.type === 'number' || base?.type === 'integer') return 'number';
|
|
104
65
|
if (base?.type === 'boolean') return 'boolean';
|
|
@@ -108,7 +69,7 @@ function controlOf(field: Field): FormField['control'] {
|
|
|
108
69
|
|
|
109
70
|
/** A closed set's members, when the shape declares one — `oneOf('draft','live')`. */
|
|
110
71
|
function enumOf(field: Field): readonly (string | null)[] | undefined {
|
|
111
|
-
const base =
|
|
72
|
+
const base = Shapes.of(field.shape).base;
|
|
112
73
|
return base?.type === 'string' ? base.enum : undefined;
|
|
113
74
|
}
|
|
114
75
|
|
|
@@ -117,7 +78,7 @@ const INPUT_TYPES = new Set(['text', 'email', 'url', 'number']);
|
|
|
117
78
|
|
|
118
79
|
/** The shape's bounds, under the names a browser already enforces. */
|
|
119
80
|
function attrsOf(field: Field, control: FormField['control'], required: boolean): NonNullable<FormField['attrs']> {
|
|
120
|
-
const base =
|
|
81
|
+
const base = Shapes.of(field.shape).base;
|
|
121
82
|
const text = base?.type === 'string' ? base : undefined;
|
|
122
83
|
const numeric = base?.type === 'number' || base?.type === 'integer' ? base : undefined;
|
|
123
84
|
const attrs = {
|
|
@@ -140,11 +101,7 @@ function labelOf(name: string, entityKey: string): Pick<FormField, 'labelKey' |
|
|
|
140
101
|
return { labelKey: `${entityKey}.${name}`, label: name.charAt(0).toUpperCase() + name.slice(1) };
|
|
141
102
|
}
|
|
142
103
|
|
|
143
|
-
/**
|
|
144
|
-
* The fields a create form is made of: membership from the io projection
|
|
145
|
-
* (`Visibility.input` — what a client may supply), requiredness from the
|
|
146
|
-
* lifecycle axis (any create rule makes absence legal).
|
|
147
|
-
*/
|
|
104
|
+
/** The fields a create form is made of. */
|
|
148
105
|
export function formFieldsOf(entity: FormEntity, entityKey: string): FormField[] {
|
|
149
106
|
return Object.entries(Visibility.of(entity.getFields()).input).map(([name, field]) => {
|
|
150
107
|
const f = field;
|
|
@@ -175,18 +132,14 @@ export interface TableColumn {
|
|
|
175
132
|
/** The same key a form uses for the same field — one convention, two projections. */
|
|
176
133
|
labelKey: string;
|
|
177
134
|
label: string;
|
|
178
|
-
/**
|
|
179
|
-
* The entity a `link` points at, under the key its door is named by. Always present on a
|
|
180
|
-
* reference: the card carries the target's name, and a card rebuilt with no sibling to
|
|
181
|
-
* resolve to keeps it as a stand-in rather than losing it.
|
|
182
|
-
*/
|
|
135
|
+
/** The entity a `link` points at, under the key its door is named by. */
|
|
183
136
|
to?: string;
|
|
184
137
|
}
|
|
185
138
|
|
|
186
139
|
/** Asked of the relation before the shape: a reference's own shape is a bare string. */
|
|
187
140
|
function renderOf(field: Field): TableColumn['render'] {
|
|
188
141
|
if (Role.of(field).isReference) return 'link';
|
|
189
|
-
const base =
|
|
142
|
+
const base = Shapes.of(field.shape).base;
|
|
190
143
|
if (base?.type === 'number' || base?.type === 'integer') return 'number';
|
|
191
144
|
if (base?.type === 'boolean') return 'boolean';
|
|
192
145
|
if (base?.type === 'object' || base?.type === 'array') return 'json';
|
|
@@ -194,13 +147,7 @@ function renderOf(field: Field): TableColumn['render'] {
|
|
|
194
147
|
return 'text';
|
|
195
148
|
}
|
|
196
149
|
|
|
197
|
-
/**
|
|
198
|
-
* The columns a list is made of: membership from the io projection (`Visibility.output` — what
|
|
199
|
-
* may leave), minus collections, because a cell holds one value and a `many()` is a page.
|
|
200
|
-
*
|
|
201
|
-
* Which column identifies the row is NOT answered here — `FieldSet.primary` answers it for a
|
|
202
|
-
* shape, and its own doc records what five private copies of that loop cost.
|
|
203
|
-
*/
|
|
150
|
+
/** The columns a list is made of. */
|
|
204
151
|
export function tableColumnsOf(entity: FormEntity, entityKey: string): TableColumn[] {
|
|
205
152
|
return Object.entries(Visibility.of(entity.getFields()).output)
|
|
206
153
|
.filter(([, field]) => !Role.of(field).isCollection)
|
|
@@ -216,9 +163,9 @@ export function tableColumnsOf(entity: FormEntity, entityKey: string): TableColu
|
|
|
216
163
|
}
|
|
217
164
|
|
|
218
165
|
/**
|
|
219
|
-
* The wire body of the form's values — an empty control is an absent value
|
|
220
|
-
*
|
|
221
|
-
*
|
|
166
|
+
* The wire body of the form's values — an empty control is an absent value at the create boundary
|
|
167
|
+
* (absence is validated by the lifecycle axis, an empty string would be validated as a present bad
|
|
168
|
+
* value).
|
|
222
169
|
*/
|
|
223
170
|
export function payloadOf(values: Record<string, unknown>): Record<string, unknown> {
|
|
224
171
|
return Object.fromEntries(
|
|
@@ -226,7 +173,7 @@ export function payloadOf(values: Record<string, unknown>): Record<string, unkno
|
|
|
226
173
|
);
|
|
227
174
|
}
|
|
228
175
|
|
|
229
|
-
/** Index
|
|
176
|
+
/** Index validator errors by field — local validator and remote validator share this shape. */
|
|
230
177
|
export function errorsByField(errors: ValidationError[]): Record<string, string> {
|
|
231
178
|
const byField: Record<string, string> = {};
|
|
232
179
|
for (const err of errors) {
|
package/src/graphql.ts
CHANGED
|
@@ -1,25 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The GraphQL door — declared like the others, mounted like the others.
|
|
3
|
-
*
|
|
4
|
-
* Until now GraphQL was the odd one out: `adapters: { graphql: true }` was
|
|
5
|
-
* declarable and nothing read it, while an app that wanted a schema wrote fifteen
|
|
6
|
-
* lines of its own (a Pothos builder, a query type, a mutation type, `registerAll`,
|
|
7
|
-
* then `registerGraphQL` on a router). Every one of those lines is convention —
|
|
8
|
-
* there is no decision in them — so the app declaring the adapter is enough.
|
|
9
|
-
*
|
|
10
|
-
* The package is imported LAZILY, the same shape `db: 'sqlite'` uses (`resolveStorage`
|
|
11
|
-
* pulls `@fougere/adapter-sql` only when a database is declared). A schema builder is
|
|
12
|
-
* heavy and most apps serve none, so a host must not carry one to find out. Declaring
|
|
13
|
-
* the adapter without installing it is refused by name, the way an unresolvable dialect is.
|
|
14
|
-
*
|
|
15
|
-
* This file used to BUILD the schema — a Pothos builder, `registerAll`, then `graphql()`
|
|
16
|
-
* — which made it the only schema constructor in the repo, in the package least entitled
|
|
17
|
-
* to be one. Two costs, one cause: the derivation sat away from the adapter whose job it
|
|
18
|
-
* is, and `graphql` guards its types with `instanceOf`, so a schema built on one side of
|
|
19
|
-
* the package boundary was refused on the other as coming *"from another module or
|
|
20
|
-
* realm"*. Both are gone: `@fougere/adapter-graphql` derives and executes, this door
|
|
21
|
-
* translates the result into an `Outcome`.
|
|
22
|
-
*/
|
|
1
|
+
/** The GraphQL door — declared like the others, mounted like the others. */
|
|
23
2
|
import type { App } from '@fougere/core';
|
|
24
3
|
import type { Outcome } from './serve.js';
|
|
25
4
|
|
|
@@ -56,12 +35,7 @@ async function executor(): Promise<ExecuteOn> {
|
|
|
56
35
|
}
|
|
57
36
|
}
|
|
58
37
|
|
|
59
|
-
/**
|
|
60
|
-
* Execute a GraphQL request against the app's own operations.
|
|
61
|
-
*
|
|
62
|
-
* `pass` when the app declares no GraphQL adapter — the same answer `serveRest` gives,
|
|
63
|
-
* so a host that mounted the door takes nothing away from an app that did not ask for it.
|
|
64
|
-
*/
|
|
38
|
+
/** Execute a GraphQL request against the app's own operations. */
|
|
65
39
|
export async function serveGraphQL(app: App, request: GraphQLRequest): Promise<Outcome> {
|
|
66
40
|
if (!app.adapters?.graphql) return { kind: 'pass' };
|
|
67
41
|
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@fougere/app` — what an app host needs and what no host owns.
|
|
3
|
-
*
|
|
4
|
-
* Two adapters read this package: `@fougere/nuxt` and `@fougere/next`. Between
|
|
5
|
-
* them they share the boot, the three doors' decisions, the REST table, the form
|
|
6
|
-
* contract and the session view; what they do NOT share is how a request arrives,
|
|
7
|
-
* how routes are mounted, and how a value becomes reactive.
|
|
8
|
-
*
|
|
9
|
-
* The root entry pulls in the boot, which reads the filesystem — client code wants
|
|
10
|
-
* `@fougere/app/client`.
|
|
11
|
-
*/
|
|
1
|
+
/** `@fougere/app` — what an app host needs and what no host owns. */
|
|
12
2
|
export {
|
|
13
3
|
configureFougere,
|
|
14
4
|
extendFougere,
|
package/src/rest.ts
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The REST table this app serves, and the rule that matches a request against it.
|
|
3
|
-
*
|
|
4
|
-
* Separated from the h3 handler on purpose: this file holds the whole of what the door
|
|
5
|
-
* DECIDES — which operation a verb and a path name — while `api/crud.ts` only translates
|
|
6
|
-
* that decision into h3. The rule was previously inline, untested, and had drifted into a
|
|
7
|
-
* second REST projection that answered differently from `schema-rest` on all three counts
|
|
8
|
-
* (verb, path, exposure).
|
|
9
|
-
*/
|
|
1
|
+
/** The REST table this app serves, and the rule that matches a request against it. */
|
|
10
2
|
import { generateRoutes } from '@fougere/adapter-rest';
|
|
11
3
|
import type { App } from '@fougere/core';
|
|
12
4
|
|
|
@@ -31,14 +23,7 @@ export type RouteMatch =
|
|
|
31
23
|
// it changes exactly when the app does.
|
|
32
24
|
const tables = new WeakMap<App, Matchable[]>();
|
|
33
25
|
|
|
34
|
-
/**
|
|
35
|
-
* The table, per frond.
|
|
36
|
-
*
|
|
37
|
-
* `generateRoutes` prefixes every path the same way, while this door addresses a frond by
|
|
38
|
-
* name (`/api/{frond}/{plural}`) — so it runs once per frond, each with its own prefix and
|
|
39
|
-
* a filter naming it. That frond loop is the only thing this file knows that `schema-rest`
|
|
40
|
-
* does not; the verbs, the paths and the membership rule all come from there.
|
|
41
|
-
*/
|
|
26
|
+
/** The table, per frond. */
|
|
42
27
|
export function tableOf(app: App): Matchable[] {
|
|
43
28
|
const cached = tables.get(app);
|
|
44
29
|
if (cached) return cached;
|
|
@@ -78,25 +63,10 @@ function openness(route: Matchable): number {
|
|
|
78
63
|
return route.segments.filter((s) => s.startsWith(':')).length;
|
|
79
64
|
}
|
|
80
65
|
|
|
81
|
-
/**
|
|
82
|
-
* Verbs this door accepts in place of the one the table names.
|
|
83
|
-
*
|
|
84
|
-
* `deriveMethod` gives `update` a single verb, PUT, while this door has always served
|
|
85
|
-
* PATCH on it too — and says so (`docs/infra/surfaces`, "PUT · PATCH"). An alias keeps
|
|
86
|
-
* that promise without giving the table a second row: both are mutations on a row, so
|
|
87
|
-
* nothing is widened, and the one thing the table decides — WHICH operation a path names —
|
|
88
|
-
* is still decided there alone.
|
|
89
|
-
*/
|
|
66
|
+
/** Verbs this door accepts in place of the one the table names. */
|
|
90
67
|
const ALIASES: Record<string, string> = { PATCH: 'PUT' };
|
|
91
68
|
|
|
92
|
-
/**
|
|
93
|
-
* Path first, method second — a router's order, and what makes a 405 possible at all.
|
|
94
|
-
*
|
|
95
|
-
* The order matters where the two overlap: `/posts/publish` and `/posts/:id` both accept
|
|
96
|
-
* `GET /posts/publish`. Taking the most specific path first means the answer is "that verb
|
|
97
|
-
* is refused here", not `findById('publish')` — and never `publish()`, which is what this
|
|
98
|
-
* door used to do with the caller's session cookie attached.
|
|
99
|
-
*/
|
|
69
|
+
/** Path first, method second — a router's order, and what makes a 405 possible at all. */
|
|
100
70
|
export function matchRoute(table: Matchable[], method: string, segments: string[]): RouteMatch {
|
|
101
71
|
const matches = table
|
|
102
72
|
.map((route) => ({ route, params: paramsOf(route, segments) }))
|
package/src/serve.ts
CHANGED
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The three doors, decided — and nothing about how a request arrives.
|
|
3
|
-
*
|
|
4
|
-
* A host (Nuxt, Next) owns exactly two translations: read the request into the
|
|
5
|
-
* plain values below, and write the outcome back out. Everything between — which
|
|
6
|
-
* operation a verb and a path name, which audience a segment selects, what a
|
|
7
|
-
* refusal becomes — is decided here, once, for every host.
|
|
8
|
-
*
|
|
9
|
-
* The split matters because the alternative was measured in this repo: two copies
|
|
10
|
-
* of a REST rule drifted until the Nuxt door answered differently from
|
|
11
|
-
* `schema-rest` on the verb, the path AND the exposure. A second host would have
|
|
12
|
-
* been a third copy.
|
|
13
|
-
*/
|
|
1
|
+
/** The three doors, decided — and nothing about how a request arrives. */
|
|
14
2
|
import {
|
|
15
3
|
createAppRunner,
|
|
16
4
|
callValueOf,
|
|
@@ -29,11 +17,7 @@ export interface DoorRequest {
|
|
|
29
17
|
path: string;
|
|
30
18
|
query: Record<string, string>;
|
|
31
19
|
body?: unknown;
|
|
32
|
-
/**
|
|
33
|
-
* The server-resolved session. Stamped by the host from what IT resolved —
|
|
34
|
-
* never taken from the wire, which is the whole trust boundary of the browser
|
|
35
|
-
* door (`transport/http/src/server.ts` carries the same warning for the split).
|
|
36
|
-
*/
|
|
20
|
+
/** The server-resolved session. */
|
|
37
21
|
state: Record<string, unknown>;
|
|
38
22
|
}
|
|
39
23
|
|
|
@@ -46,31 +30,16 @@ export type Outcome =
|
|
|
46
30
|
|
|
47
31
|
// ── The call envelope ────────────────────────────
|
|
48
32
|
|
|
49
|
-
/**
|
|
50
|
-
* The audience this door serves — the path segment after `/_fougere/call`.
|
|
51
|
-
*
|
|
52
|
-
* The envelope is a surface like REST and GraphQL, so it selects its audience like
|
|
53
|
-
* they do; the difference is only that it takes it from the path instead of an
|
|
54
|
-
* option, because a door is mounted, not called. The same word names the directory
|
|
55
|
-
* (`handlers/public/`), the config key (`surfaces: { public: [...] }`) and this
|
|
56
|
-
* segment — derived, never configured.
|
|
57
|
-
*
|
|
58
|
-
* No escalation to guard: a named surface serves the entities it names and nothing
|
|
59
|
-
* else (closed by naming), so every one of them is a subset of what the bare path
|
|
60
|
-
* already serves.
|
|
61
|
-
*/
|
|
33
|
+
/** The audience this door serves — the path segment after `/_fougere/call`. */
|
|
62
34
|
export function surfaceOf(path: string): string | undefined {
|
|
63
35
|
const named = /^\/_fougere\/call\/([A-Za-z0-9_-]+)/.exec(path.replace(/\?.*$/, ''));
|
|
64
36
|
return named?.[1];
|
|
65
37
|
}
|
|
66
38
|
|
|
67
39
|
/**
|
|
68
|
-
* Receiving end for the browser — same wire as process-to-process (JSON-RPC),
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* The runner follows the app's topology: local façades and remote doublures alike
|
|
73
|
-
* — the browser never knows where a Frond lives.
|
|
40
|
+
* Receiving end for the browser — same wire as process-to-process (JSON-RPC), different trust
|
|
41
|
+
* boundary: the browser sits outside the topology, so `state` is whatever the host resolved
|
|
42
|
+
* server-side, never what the payload claims.
|
|
74
43
|
*/
|
|
75
44
|
export async function serveRpc(app: App, request: Pick<DoorRequest, 'path' | 'body' | 'state'>): Promise<unknown> {
|
|
76
45
|
const runner = createAppRunner(app, surfaceOf(request.path));
|
|
@@ -85,12 +54,7 @@ export function rpcParseError() {
|
|
|
85
54
|
// ── REST ─────────────────────────────────────────
|
|
86
55
|
|
|
87
56
|
/**
|
|
88
|
-
* Match the URL against the canonical table, invoke the call it names, shape the
|
|
89
|
-
* result for HTTP. The decision lives in `rest.ts`; dispatch belongs to the runner.
|
|
90
|
-
*
|
|
91
|
-
* `path` is what follows the REST mount point, so `/api/blog/posts/1` arrives as
|
|
92
|
-
* `blog/posts/1`. A path this door does not serve returns `pass`, and that is what
|
|
93
|
-
* lets an app keep its own `/api/*` handlers.
|
|
57
|
+
* Match the URL against the canonical table, invoke the call it names, shape the result for HTTP.
|
|
94
58
|
*/
|
|
95
59
|
export async function serveRest(app: App, request: DoorRequest): Promise<Outcome> {
|
|
96
60
|
// The app decides, not the host. A route file may exist and a middleware may be
|
|
@@ -123,7 +87,7 @@ export async function serveRest(app: App, request: DoorRequest): Promise<Outcome
|
|
|
123
87
|
result = await invokeOn(
|
|
124
88
|
app,
|
|
125
89
|
{ entity: route.entityName, op: route.operationName },
|
|
126
|
-
{ params, query: request.query,
|
|
90
|
+
{ params, query: request.query, input: request.body },
|
|
127
91
|
undefined,
|
|
128
92
|
request.state,
|
|
129
93
|
);
|
|
@@ -135,13 +99,7 @@ export async function serveRest(app: App, request: DoorRequest): Promise<Outcome
|
|
|
135
99
|
return shapeRest(route.operationName, result);
|
|
136
100
|
}
|
|
137
101
|
|
|
138
|
-
/**
|
|
139
|
-
* What an operation's return becomes on the wire.
|
|
140
|
-
*
|
|
141
|
-
* Separate from `serveRest` because it is a DECISION and dispatch is not: it can be
|
|
142
|
-
* pinned without a runner, and both hosts get it whether the call ran in memory or
|
|
143
|
-
* came back over JSON-RPC.
|
|
144
|
-
*/
|
|
102
|
+
/** What an operation's return becomes on the wire. */
|
|
145
103
|
export function shapeRest(operationName: string, result: unknown): Outcome {
|
|
146
104
|
if (result === null) return { kind: 'error', status: 404, body: { message: 'Not found' } };
|
|
147
105
|
|
|
@@ -168,13 +126,8 @@ type EntityClass = { name: string };
|
|
|
168
126
|
type CallInput = Partial<InvocationContext>;
|
|
169
127
|
|
|
170
128
|
/**
|
|
171
|
-
* Name a call server-side and let the runner place it — local façade → direct
|
|
172
|
-
*
|
|
173
|
-
* never knows which.
|
|
174
|
-
*
|
|
175
|
-
* `state` is explicit here. Each host wraps this with its own way of finding the
|
|
176
|
-
* current request (Nitro's async context, Next's `headers()` scope), because that
|
|
177
|
-
* is the one part a host actually owns.
|
|
129
|
+
* Name a call server-side and let the runner place it — local façade → direct in-memory execution,
|
|
130
|
+
* a frond in `remotes` → JSON-RPC on the wire.
|
|
178
131
|
*/
|
|
179
132
|
export async function invokeOn<T = unknown>(
|
|
180
133
|
app: App,
|
|
@@ -186,6 +139,6 @@ export async function invokeOn<T = unknown>(
|
|
|
186
139
|
const { call, invocation } = callValueOf(target, opOrInput, input);
|
|
187
140
|
// An explicit `state` on the input wins over the request's — the caller who spells
|
|
188
141
|
// it is answering for it, which is what makes a call outside any request possible.
|
|
189
|
-
const
|
|
190
|
-
return (await createAppRunner(app)(call, { ...invocation, state:
|
|
142
|
+
const explicit = typeof opOrInput === 'string' ? input : opOrInput;
|
|
143
|
+
return (await createAppRunner(app)(call, { ...invocation, state: explicit?.state ?? state })) as T;
|
|
191
144
|
}
|
package/src/session.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The session view — the one place that turns the server-resolved
|
|
3
|
-
*
|
|
4
|
-
* is allowed to see. One resolution, three readers: the page by
|
|
5
|
-
* hydration, the refresh route over the wire, handlers by invocation.
|
|
6
|
-
*
|
|
7
|
-
* The app-declared context (viewer enrichment) will attach here when
|
|
8
|
-
* a real case lands — this function is the seam.
|
|
2
|
+
* The session view — the one place that turns the server-resolved request context (filled by the
|
|
3
|
+
* auth middleware) into what the client is allowed to see.
|
|
9
4
|
*/
|
|
10
5
|
|
|
11
6
|
export interface SessionView {
|
package/src/state.ts
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Who the caller is, resolved server-side from the request's own headers.
|
|
3
|
-
*
|
|
4
|
-
* Nuxt answers this with a Nitro middleware that stamps `event.context`; a
|
|
5
|
-
* Web-standard host has no such seam on a route handler, so it resolves here
|
|
6
|
-
* instead. Same source (the auth runtime mounted on `app.auth`), same result shape
|
|
7
|
-
* (`{ user, session }`), so `serveRpc` and `serveRest` cannot tell hosts apart.
|
|
8
|
-
*
|
|
9
|
-
* What must stay true in both: this is what the SERVER resolved. A browser sits
|
|
10
|
-
* outside the topology, so nothing here may come from the payload.
|
|
11
|
-
*/
|
|
1
|
+
/** Who the caller is, resolved server-side from the request's own headers. */
|
|
12
2
|
import { useFougereApp } from './boot.js';
|
|
13
3
|
|
|
14
4
|
type SessionApi = {
|