@fougere/app 0.5.0-alpha.1 → 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 -96
  5. package/dist/boot.d.ts.map +1 -1
  6. package/dist/boot.js +19 -217
  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 +3 -12
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -12
  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 -10
  48. package/src/auth.ts +1 -7
  49. package/src/boot.ts +31 -260
  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 +4 -12
  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/boot.ts CHANGED
@@ -1,98 +1,35 @@
1
1
  import { Role } from '@fougere/schema';
2
2
  import { Lifecycle } from '@fougere/schema';
3
- /**
4
- * Fougere server bootstrap — single entry point for an app's lifecycle,
5
- * whatever hosts it.
6
- *
7
- * Nothing here knows h3, Nitro, Vue or React: a boot reads `fougere.config.ts`,
8
- * scans fronds off the filesystem and hands back an `App`. That is why it lives
9
- * in `@fougere/app` rather than in one of the two adapters — the second host
10
- * would otherwise have copied it, and a copied boot drifts (the seeding loop
11
- * already did, `core/src/boot/seed.ts`).
12
- *
13
- * Default path (zero-config): fougere.config.ts declares `db: 'sqlite'` and
14
- * everything else; this module auto-resolves the storage handle, builds an
15
- * ormFactory, runs auto-DDL from the entities, and boots the app.
16
- *
17
- * Escape hatch: `configureFougere({ db, ormFactory })` can be called from the
18
- * host's own startup (a Nitro plugin, a Next instrumentation hook) if the user
19
- * wants a custom data layer — alternative driver, managed migrations, etc.
20
- */
3
+ /** Fougere server bootstrap — single entry point for an app's lifecycle, whatever hosts it. */
21
4
  import { createApp, identityFromEnv, Logger, migrating, seeding } from '@fougere/core';
22
5
  import { scanProject, loadCascadedConfig, setModuleLoader, frondAliases, resolveConventions } from '@fougere/core/node';
23
6
  import type { Extension } from '@fougere/core';
24
7
  import { createContainer } from '@fougere/container';
25
- import type { App, CreateAppOptions, EntityOrm, FougereConfig, Transport } from '@fougere/core';
8
+ import { createMemoryStorage } from '@fougere/adapter-memory';
9
+ import type { App, CreateAppOptions, Storage, FougereConfig, Transport } from '@fougere/core';
10
+ import type { ResolvedStorage } from '@fougere/defaults';
26
11
  import { applyCreate, applyUpdate, type SchemaView } from '@fougere/schema';
27
12
 
28
13
  // ── Public types ─────────────────────────────────
29
14
 
30
15
  export interface FougereServerConfig {
31
- /** Storage handle. Forwarded to the auth provider via AuthContext.db. */
32
- db?: unknown;
33
- /** Per-entity ORM factory. */
34
- ormFactory?: (entity: SchemaView, name: string) => EntityOrm;
35
16
  /**
36
- * What this app takes on beyond its fronds, each stating what it does and what it undoes.
37
- *
38
- * It replaced `afterBoot`, which a host used to CLAIM the whole post-boot to get its own
39
- * seeding — Nuxt's generated plugin did exactly that, and its copy of the seeding loop
40
- * drifted. Declaring `{ name: 'seeds', up }` replaces that one member and leaves the
41
- * rest of the ascent alone.
17
+ * The whole data layer, as `resolveStorage` composed it — where the rows are, which source
18
+ * transacts, what to close. Handed over as ONE subject: a host that passed the factory alone
19
+ * lost the transaction and the connection's owner, and nothing said so.
42
20
  */
21
+ storage?: ResolvedStorage;
22
+ /** What this app takes on beyond its fronds, each stating what it does and what it undoes. */
43
23
  extensions?: CreateAppOptions['extensions'];
44
- /**
45
- * What the boot line names as the host — 'Nuxt/Nitro', 'Next'. Stated by the
46
- * adapter, never sniffed: a boot that guesses its host from what happens to be
47
- * importable is the hidden runtime the doctrine refuses.
48
- */
24
+ /** What the boot line names as the host — 'Nuxt/Nitro', 'Next'. */
49
25
  host?: string;
50
- /**
51
- * What this app is built from, when the host already knows.
52
- *
53
- * Absent, `boot()` reads the fronds off the disk — right on a server, impossible on a
54
- * runtime that has none: measured on workerd, `readdir` throws through unenv's shim and
55
- * the app comes up with ZERO fronds, so every page renders and every door answers
56
- * NOT_FOUND. A host that scanned at BUILD time can say so instead, and `fougere build`
57
- * writes exactly this value down.
58
- *
59
- * Same slot as `CreateAppOptions.scan` and for the same reason: producing the value
60
- * reads a disk, consuming it does not.
61
- */
26
+ /** What this app is built from, when the host already knows. */
62
27
  scan?: CreateAppOptions['scan'];
63
- /**
64
- * What this app STATES it hosts — `frond('blog', { entities: [Post] })`.
65
- *
66
- * Stating this and no `scan` is how a host stops scanning at boot: nothing reads a
67
- * disk, nothing loads `typescript`, and what was not named does not exist. It is the
68
- * one door Next, Vite, React, Svelte and a bare Express share — none of them scans on
69
- * its own, they all arrive here, and this line used to end in a scan for every one.
70
- */
28
+ /** What this app STATES it hosts — `frond('blog', { entities: [Post] })`. */
71
29
  fronds?: CreateAppOptions['fronds'];
72
- /**
73
- * What `fougere.config.ts` says, when the host already read it.
74
- *
75
- * The same rule as `scan`, and found the same way: `boot()` re-reads the file at
76
- * runtime, which a Worker cannot do — measured, a consumer's `remotes:` never reached
77
- * the boot and its pages rendered empty with nothing said. A host that read the config
78
- * at BUILD time states it here instead.
79
- *
80
- * `auth` is deliberately not part of what a codegen'd host can carry: it holds a live
81
- * provider, not a value. An app that authenticates reads its own config.
82
- */
30
+ /** What `fougere.config.ts` says, when the host already read it. */
83
31
  config?: Partial<FougereConfig>;
84
- /**
85
- * Who performs an outgoing call, when the default cannot.
86
- *
87
- * `boot()` builds an HTTP transport from `remotes:` and that is right nearly
88
- * everywhere. It is not right on Cloudflare: a Worker calling a sibling's public URL
89
- * is refused by the edge with error 1042, so two Workers of one account reach each
90
- * other through a SERVICE BINDING and through nothing else. A binding is a value only
91
- * the host holds, so only the host can state this.
92
- *
93
- * It replaces the default entirely — signing included, since a host that builds its
94
- * own transport is the one that knows what to put on the wire.
95
- */
32
+ /** Who performs an outgoing call, when the default cannot. */
96
33
  remoteTransport?: (url: string) => Transport;
97
34
  }
98
35
 
@@ -112,15 +49,7 @@ export function configureFougere(config: FougereServerConfig) {
112
49
  _appPromise = null;
113
50
  }
114
51
 
115
- /**
116
- * Add to what is already stated, instead of replacing it.
117
- *
118
- * A host states its app in PIECES when the pieces are known at different moments: a
119
- * build writes the scan and the config into a generated plugin, and a value only the
120
- * running process holds — a Cloudflare service binding — cannot be written there at all.
121
- * Its dual is `configureFougere`, which replaces; `reloadFougere` depends on that
122
- * replacement, so merging silently would have broken the turn of the ring.
123
- */
52
+ /** Add to what is already stated, instead of replacing it. */
124
53
  export function extendFougere(config: Partial<FougereServerConfig>) {
125
54
  _config = { ..._config, ...config };
126
55
  _appPromise = null;
@@ -134,22 +63,7 @@ export function useFougereApp(): Promise<App> {
134
63
  return _appPromise;
135
64
  }
136
65
 
137
- /**
138
- * Turn the ring: instantiate the app again, then let the previous one go.
139
- *
140
- * This is what "reload" means for anything the config CONSUMED — a value that built
141
- * something cannot move under what it built, so the thing is built again. Its dual is
142
- * `applyConfig`, for values that are merely consulted and need no turn at all.
143
- *
144
- * Every door reaches the app through `useFougereApp()` inside the request it serves and
145
- * none holds it across two, which is what makes the swap invisible: the next request
146
- * lands on the new app whether or not the old one has finished being released.
147
- *
148
- * A call already running finishes on the OLD app: it is drained before being released,
149
- * so nothing has its storage closed underneath it. `timeoutMs` bounds that wait, and a
150
- * drain that runs out REJECTS — the app is left alone rather than released under work,
151
- * because a caller who cannot wait must choose that on purpose.
152
- */
66
+ /** Turn the ring. */
153
67
  export async function reloadFougere(timeoutMs?: number): Promise<App> {
154
68
  const previous = _appPromise;
155
69
  _appPromise = null;
@@ -208,32 +122,16 @@ async function boot(): Promise<App> {
208
122
  // Auto-resolve the data layer from config.db when the user didn't provide a
209
123
  // custom one via configureFougere. The resolution itself lives in @fougere/defaults
210
124
  // — this host must not know which storage package backs `db:`.
211
- let db = _config.db;
212
- let ormFactory = _config.ormFactory;
213
- // The storage's two halves, kept together: its ascent is an extension, its connection
214
- // is not — it is opened here, before the container, so it closes after the container.
215
- let storageMigrate: Extension['up'] | undefined;
216
- let closeStorage: (() => Promise<void>) | undefined;
217
- // Where the rows are, and how to open a transaction there — read from the same storage
218
- // resolution, because a frame's realization is decided by `sources:` and nothing else.
219
- let sourceOf: ((entityName: string) => string) | undefined;
220
- let transacted: CreateAppOptions['transacted'];
221
- if (!ormFactory) {
125
+ let storage = _config.storage;
126
+ if (!storage) {
222
127
  const { resolveStorage } = await import('@fougere/defaults');
223
- const storage = resolveStorage(fileConfig.db as never, (fileConfig as { sources?: unknown }).sources as never);
224
- if (storage.ormFactory) {
225
- log.debug('auto-resolving storage from config.db');
226
- db = storage.db;
227
- ormFactory = storage.ormFactory;
228
- sourceOf = storage.sourceOf;
229
- transacted = storage.transacted as never;
230
- storageMigrate = storage.migrate;
231
- closeStorage = storage.close;
232
- } else {
233
- log.debug('no db declared — falling back to in-memory ORM');
234
- ormFactory = createMemoryOrm;
235
- }
128
+ storage = resolveStorage(fileConfig.db as never, (fileConfig as { sources?: unknown }).sources as never);
129
+ log.debug(storage.storageFactory ? 'auto-resolving storage from config.db' : 'no db declared — falling back to in-memory storage');
236
130
  }
131
+ // The storage's two halves, kept together: its ascent is an extension, its connection
132
+ // is not — it is opened here, before the container, so it closes after the container.
133
+ const storageFactory = storage.storageFactory ?? createMemoryStorage;
134
+ const storageMigrate: Extension['up'] | undefined = storage.migrate;
237
135
 
238
136
  // Layer-2 wiring: `remotes: { catalog: 'http://...' }` in fougere.config.ts
239
137
  // is all the user writes — the default transport comes from here.
@@ -265,19 +163,16 @@ async function boot(): Promise<App> {
265
163
  ? { scan: await scanProject(root, undefined, conventions) }
266
164
  : {}),
267
165
  createContainer,
268
- ormFactory,
269
- sourceOf,
270
- transacted,
271
- db,
166
+ storageFactory,
167
+ sourceOf: storage.sourceOf,
168
+ transacts: storage.transacts,
169
+ transacted: storage.transacted as never,
170
+ db: storage.db,
272
171
  auth: fileConfig.auth,
273
172
  adapters: fileConfig.adapters,
274
173
  remotes: fileConfig.remotes,
275
174
  remoteTransport,
276
- /**
277
- * The whole ascent, one ordered list: tables, then rows, then what the host adds.
278
- * A host wanting its OWN seeding declares `{ name: 'seeds', … }` and replaces that
279
- * member — it no longer has to claim everything after the boot to get it.
280
- */
175
+ /** The whole ascent, one ordered list. */
281
176
  extensions: [
282
177
  // The slot is declared even when this host resolved no storage — a host that resolved
283
178
  // its own (the Nitro plugin does, for its bundler) then REPLACES this member in place
@@ -289,7 +184,7 @@ async function boot(): Promise<App> {
289
184
  // Opened before the container, so released after it. Never wired here until now:
290
185
  // this host boots the storage and no host closed one, which is what made a reload
291
186
  // leak the pool of every app it discarded.
292
- onDispose: closeStorage,
187
+ onDispose: storage.close,
293
188
  });
294
189
 
295
190
  log.info(`ascent: ${app.extensions().join(' → ') || 'nothing declared'}`);
@@ -300,127 +195,3 @@ async function boot(): Promise<App> {
300
195
  return app;
301
196
  }
302
197
 
303
- // ── Fallback ORM ─────────────────────────────────
304
-
305
- /**
306
- * The store an app with no `db` runs on.
307
- *
308
- * It used to ignore both its arguments — `(_entity, _name)` — so it forced the field
309
- * name `id`, minted a uuid whatever the entity declared, and realized none of the
310
- * lifecycle rules: `created()` stamped nothing, a declared default stayed absent. The
311
- * same page therefore behaved one way here and another way on SQLite.
312
- *
313
- * It reads the axes now, through the one realization every storage shares.
314
- */
315
- export function createMemoryOrm(entity: SchemaView, name: string): EntityOrm {
316
- const fields = entity.getFields();
317
- const pk = Object.entries(fields).find(([, field]) => Role.of(field).isPrimary)?.[0] ?? 'id';
318
- const store = new Map<string, Record<string, unknown>>();
319
- // `EntityOrm.findById(id: string)` — but a key can hold a number, and a Map keyed on
320
- // `1` does not answer `'1'`. SQL never had the question; here the divergence was
321
- // silent and only on this storage.
322
- const keyOf = (value: unknown) => String(value);
323
- // Same contract as SQL: a criterion may name a SET, and an empty set matches nothing.
324
- const matches = (row: Record<string, unknown>, criteria: Record<string, unknown>) =>
325
- Object.entries(criteria).every(([key, value]) => Array.isArray(value)
326
- ? value.some((v) => Object.is(row[key], v))
327
- : Object.is(row[key], value));
328
- return {
329
- client: store,
330
- async list(options?: any) {
331
- let items = [...store.values()];
332
- if (options?.where) items = items.filter((row) => matches(row, options.where));
333
- // Held before the page is cut, and after the filter: `total` answers "how many
334
- // match", which is what a paginator divides. Reading `store.size` at the end
335
- // answered a different question — every row the store holds, including the ones
336
- // the filter exists to keep out of this caller's sight.
337
- const matching = items.length;
338
- const limit = options?.limit;
339
- const offset = options?.page && limit ? (options.page - 1) * limit : options?.offset ?? 0;
340
- if (offset > 0) items = items.slice(offset);
341
- const hasMore = limit ? items.length > limit : false;
342
- if (limit) items = items.slice(0, limit);
343
- const result = items as any;
344
- result.hasMore = hasMore;
345
- result.endCursor = items.length > 0 ? String((items[items.length - 1] as any)[pk] ?? '') : undefined;
346
- if (options?.count) result.total = matching;
347
- return result;
348
- },
349
- async findById(id: string) { return store.get(keyOf(id)); },
350
- async findBy(criteria: Record<string, unknown>) {
351
- return [...store.values()].find((row) => matches(row, criteria));
352
- },
353
- async findAllBy(criteria: Record<string, unknown>) {
354
- return [...store.values()].filter((row) => matches(row, criteria));
355
- },
356
- // Same contract as SQL: a map keyed by the primary key, a miss being an absent key.
357
- async findByKeys(ids: readonly string[]) {
358
- const found = new Map<string, Record<string, unknown>>();
359
- for (const id of ids) {
360
- const row = store.get(keyOf(id));
361
- if (row) found.set(String(id), row);
362
- }
363
- return found;
364
- },
365
- // The dual, same contract as SQL: grouped by the value read off the ROW.
366
- async findAllByKeys(field: string, keys: readonly string[]) {
367
- const grouped = new Map<string, Record<string, unknown>[]>();
368
- if (keys.length === 0) return grouped;
369
- const wanted = new Set(keys.map(String));
370
- for (const row of store.values()) {
371
- const key = String(row[field]);
372
- if (!wanted.has(key)) continue;
373
- const held = grouped.get(key);
374
- if (held) held.push(row); else grouped.set(key, [row]);
375
- }
376
- return grouped;
377
- },
378
- // Same contract as SQL: the key and the creation stamps survive an overwrite.
379
- async upsert(input: Partial<Record<string, unknown>>) {
380
- const record = applyCreate(fields, applyUpdate(fields, input));
381
- const id = record[pk] as string | undefined;
382
- if (id === undefined) throw new Error(`${name}.upsert(): no \`${pk}\` — an upsert needs the key it writes at.`);
383
- const held = store.get(keyOf(id));
384
- if (held) {
385
- for (const [key, field] of Object.entries(fields)) {
386
- if (key === pk || Lifecycle.of(field).stampedOnce) record[key] = held[key];
387
- }
388
- }
389
- store.set(keyOf(id), record);
390
- return record;
391
- },
392
- async upsertAll(inputs: readonly Partial<Record<string, unknown>>[]) {
393
- for (const input of inputs) await (this as any).upsert(input);
394
- return inputs.length;
395
- },
396
- async create(input: Partial<Record<string, unknown>>) {
397
- const record = applyCreate(fields, input);
398
- const id = record[pk] as string | undefined;
399
- // `primary(text())` declares no generator, so nothing fills the hole and the
400
- // caller has to. Keying on `undefined` would let the second create overwrite the
401
- // first, in silence — the old version hid this by inventing an `id` field the
402
- // entity never declared.
403
- if (id === undefined) {
404
- throw new Error(`${name}.create: '${pk}' is the primary key and nothing supplied it — this entity declares no generator for it.`);
405
- }
406
- // A create is not an upsert. `Map.set` overwrites, so a second create under the
407
- // same key answered "created" while destroying the previous row — SQL answers a
408
- // constraint violation, and a store that loses data silently is worse than one
409
- // that fails.
410
- if (store.has(keyOf(id))) {
411
- throw new Error(`${name}.create: '${pk}' ${JSON.stringify(id)} already exists.`);
412
- }
413
- store.set(keyOf(id), record);
414
- return record;
415
- },
416
- async update(id: string, input: Partial<Record<string, unknown>>) {
417
- const existing = store.get(keyOf(id));
418
- if (!existing) throw new Error(`Not found: ${id}`);
419
- const updated = { ...existing, ...applyUpdate(fields, input), [pk]: existing[pk] };
420
- store.set(keyOf(id), updated);
421
- return updated;
422
- },
423
- async delete(id: string) { return store.delete(keyOf(id)); },
424
- output(_schema: SchemaView) { return this; },
425
- };
426
- }
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);