@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.
Files changed (59) hide show
  1. package/dist/auth.d.ts +1 -7
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/auth.js.map +1 -1
  4. package/dist/boot.d.ts +14 -85
  5. package/dist/boot.d.ts.map +1 -1
  6. package/dist/boot.js +17 -77
  7. package/dist/boot.js.map +1 -1
  8. package/dist/client.d.ts +5 -24
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +6 -32
  11. package/dist/client.js.map +1 -1
  12. package/dist/express.d.ts +2 -12
  13. package/dist/express.d.ts.map +1 -1
  14. package/dist/express.js +4 -52
  15. package/dist/express.js.map +1 -1
  16. package/dist/form.d.ts +11 -56
  17. package/dist/form.d.ts.map +1 -1
  18. package/dist/form.js +15 -33
  19. package/dist/form.js.map +1 -1
  20. package/dist/graphql.d.ts +2 -28
  21. package/dist/graphql.d.ts.map +1 -1
  22. package/dist/graphql.js +1 -6
  23. package/dist/graphql.js.map +1 -1
  24. package/dist/index.d.ts +1 -11
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -11
  27. package/dist/index.js.map +1 -1
  28. package/dist/rest.d.ts +2 -16
  29. package/dist/rest.d.ts.map +1 -1
  30. package/dist/rest.js +4 -34
  31. package/dist/rest.js.map +1 -1
  32. package/dist/serve.d.ts +10 -57
  33. package/dist/serve.d.ts.map +1 -1
  34. package/dist/serve.js +12 -55
  35. package/dist/serve.js.map +1 -1
  36. package/dist/session.d.ts +2 -7
  37. package/dist/session.d.ts.map +1 -1
  38. package/dist/session.js +2 -7
  39. package/dist/session.js.map +1 -1
  40. package/dist/state.d.ts.map +1 -1
  41. package/dist/state.js +1 -11
  42. package/dist/state.js.map +1 -1
  43. package/dist/web.d.ts +3 -19
  44. package/dist/web.d.ts.map +1 -1
  45. package/dist/web.js +4 -32
  46. package/dist/web.js.map +1 -1
  47. package/package.json +11 -11
  48. package/src/auth.ts +1 -7
  49. package/src/boot.ts +28 -134
  50. package/src/client.ts +7 -33
  51. package/src/express.ts +4 -52
  52. package/src/form.ts +20 -73
  53. package/src/graphql.ts +2 -28
  54. package/src/index.ts +1 -11
  55. package/src/rest.ts +4 -34
  56. package/src/serve.ts +13 -60
  57. package/src/session.ts +2 -7
  58. package/src/state.ts +1 -11
  59. 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
- * before a framework's state primitives get involved.
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' | 'body'>>;
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: {}, body: undefined, state: {}, ...input };
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
- * from the entity's field axes. No Vue, no Nuxt: testable headless,
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 { Anatomy, lowerFirst, Role, Visibility } from '@fougere/schema';
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
- * input and the page states no rule of its own.
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`, judged later. */
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 = Anatomy.of(field.shape).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 = Anatomy.of(field.shape).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 = Anatomy.of(field.shape).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 = Anatomy.of(field.shape).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
- * at the create boundary (absence is judged by the lifecycle axis, an empty
221
- * string would be judged as a present bad value).
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 judge errors by field — local judge and remote judge share this shape. */
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
- * different trust boundary: the browser sits outside the topology, so `state` is
70
- * whatever the host resolved server-side, never what the payload claims.
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, body: request.body },
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
- * in-memory execution, a frond in `remotes` → JSON-RPC on the wire. The caller
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 given = typeof opOrInput === 'string' ? input : opOrInput;
190
- return (await createAppRunner(app)(call, { ...invocation, state: given?.state ?? state })) as T;
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
- * request context (filled by the auth middleware) into what the client
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 = {