@fougere/core 0.12.0-alpha.0 → 0.12.1-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 (88) hide show
  1. package/dist/EffectiveOperationModel.d.ts.map +1 -1
  2. package/dist/EffectiveOperationModel.js +18 -0
  3. package/dist/EffectiveOperationModel.js.map +1 -1
  4. package/dist/boot/install.d.ts.map +1 -1
  5. package/dist/boot/install.js +5 -1
  6. package/dist/boot/install.js.map +1 -1
  7. package/dist/boot/remote.d.ts.map +1 -1
  8. package/dist/boot/remote.js +8 -2
  9. package/dist/boot/remote.js.map +1 -1
  10. package/dist/contract.d.ts +2 -0
  11. package/dist/contract.d.ts.map +1 -1
  12. package/dist/contract.js +2 -0
  13. package/dist/contract.js.map +1 -1
  14. package/dist/dispatch/OutputView.d.ts.map +1 -1
  15. package/dist/dispatch/OutputView.js +7 -0
  16. package/dist/dispatch/OutputView.js.map +1 -1
  17. package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
  18. package/dist/dispatch/PresenterExecutor.js +7 -0
  19. package/dist/dispatch/PresenterExecutor.js.map +1 -1
  20. package/dist/dispatch/Received.d.ts +3 -0
  21. package/dist/dispatch/Received.d.ts.map +1 -0
  22. package/dist/dispatch/Received.js +2 -0
  23. package/dist/dispatch/Received.js.map +1 -0
  24. package/dist/dispatch/decoded.d.ts +19 -0
  25. package/dist/dispatch/decoded.d.ts.map +1 -0
  26. package/dist/dispatch/decoded.js +36 -0
  27. package/dist/dispatch/decoded.js.map +1 -0
  28. package/dist/entry/facade.d.ts +9 -2
  29. package/dist/entry/facade.d.ts.map +1 -1
  30. package/dist/entry/facade.js +16 -7
  31. package/dist/entry/facade.js.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/prefab/CrudConstructor.d.ts.map +1 -1
  37. package/dist/prefab/CrudConstructor.js +3 -2
  38. package/dist/prefab/CrudConstructor.js.map +1 -1
  39. package/dist/prefab/CrudOps.d.ts +2 -2
  40. package/dist/prefab/CrudOps.d.ts.map +1 -1
  41. package/dist/prefab/MirrorConstructor.js +1 -1
  42. package/dist/prefab/MirrorConstructor.js.map +1 -1
  43. package/dist/prefab/Refreshed.d.ts +7 -2
  44. package/dist/prefab/Refreshed.d.ts.map +1 -1
  45. package/dist/storage/Store.d.ts.map +1 -1
  46. package/dist/storage/Store.js +6 -10
  47. package/dist/storage/Store.js.map +1 -1
  48. package/dist/wire/AppMiddleware.d.ts +10 -1
  49. package/dist/wire/AppMiddleware.d.ts.map +1 -1
  50. package/dist/wire/AppMiddleware.js.map +1 -1
  51. package/dist/wire/AppNext.d.ts +2 -1
  52. package/dist/wire/AppNext.d.ts.map +1 -1
  53. package/dist/wire/Facade.d.ts +14 -2
  54. package/dist/wire/Facade.d.ts.map +1 -1
  55. package/dist/wire/Facade.js.map +1 -1
  56. package/dist/wire/FougereError.js +1 -1
  57. package/dist/wire/FougereError.js.map +1 -1
  58. package/dist/wire/OperationContract.js +1 -1
  59. package/dist/wire/OperationContract.js.map +1 -1
  60. package/dist/wire/Page.d.ts +32 -0
  61. package/dist/wire/Page.d.ts.map +1 -0
  62. package/dist/wire/Page.js +32 -0
  63. package/dist/wire/Page.js.map +1 -0
  64. package/dist/wire/Signature.d.ts +7 -0
  65. package/dist/wire/Signature.d.ts.map +1 -1
  66. package/package.json +4 -4
  67. package/src/EffectiveOperationModel.ts +19 -0
  68. package/src/boot/install.ts +4 -0
  69. package/src/boot/remote.ts +9 -2
  70. package/src/contract.ts +2 -0
  71. package/src/dispatch/OutputView.ts +7 -0
  72. package/src/dispatch/PresenterExecutor.ts +7 -0
  73. package/src/dispatch/Received.ts +2 -0
  74. package/src/dispatch/decoded.ts +37 -0
  75. package/src/entry/facade.ts +21 -9
  76. package/src/index.ts +1 -0
  77. package/src/prefab/CrudConstructor.ts +3 -2
  78. package/src/prefab/CrudOps.ts +2 -2
  79. package/src/prefab/MirrorConstructor.ts +1 -1
  80. package/src/prefab/Refreshed.ts +7 -2
  81. package/src/storage/Store.ts +6 -9
  82. package/src/wire/AppMiddleware.ts +10 -1
  83. package/src/wire/AppNext.ts +2 -1
  84. package/src/wire/Facade.ts +14 -2
  85. package/src/wire/FougereError.ts +1 -1
  86. package/src/wire/OperationContract.ts +1 -1
  87. package/src/wire/Page.ts +50 -0
  88. package/src/wire/Signature.ts +7 -0
@@ -117,6 +117,25 @@ export function resolveEffectiveOperations(
117
117
  const contract = normalizeBinding(rawContract, handler, frond, name, resolutionDiagnostics);
118
118
  if (!contract) continue;
119
119
 
120
+ // An operation answers DATA — what JSON keeps. A DECLARED output is converted by its
121
+ // fields, so `created()` leaves as an ISO string and nothing is asked of it here; an
122
+ // undeclared one has nothing to convert it, so it must already be data. Measured
123
+ // 2026-09-18: `{ at: new Date(0) }` reaches a local caller as a `Date` and a remote one
124
+ // as a string, and an object carrying methods loses them on both sides, differently.
125
+ if (!contract.output && contract.signature?.notData) {
126
+ resolutionDiagnostics.push({
127
+ severity: 'blocking',
128
+ code: 'operation-output-not-data',
129
+ filePath: handler.filePath,
130
+ frond: frond.name,
131
+ subject,
132
+ message: `${subject}() answers ${contract.signature.notData}, which carries methods.\n`
133
+ + ' An operation answers data — what JSON keeps.\n'
134
+ + ' Declare the output as an entity, whose fields convert it, or answer what it produces.',
135
+ });
136
+ continue;
137
+ }
138
+
120
139
  const override = frond.operationsOverrides?.[name];
121
140
  const inference = inferOperationKind(name);
122
141
  const inferredKinds = new Set<OperationKind>();
@@ -37,6 +37,7 @@ import { RouteAddress } from '../wire/RouteAddress.js';
37
37
  import { servedSurfaces } from '../descriptor/surface.js';
38
38
  import { OperationRoute } from '../dispatch/OperationRoute.js';
39
39
  import { facadeOperations } from '../entry/facade.js';
40
+ import { decoded } from '../dispatch/decoded.js';
40
41
 
41
42
  /**
42
43
  * What the app is made of while it is still being made. `createApp` builds these, hands
@@ -449,6 +450,9 @@ function buildFacadeInto(
449
450
  handler.address,
450
451
  routeRegistry.operationNames(handler.address, handler.surface),
451
452
  handler.surface,
453
+ // The output SCHEMA, per op: `Crud(Post, { list: PostCard })` names a different one for
454
+ // one operation, so the answer is read back through the view that op actually serves.
455
+ (operation, answer) => decoded(facade.effectiveOperations.get(operation)?.output, answer),
452
456
  ));
453
457
  }
454
458
 
@@ -15,6 +15,7 @@ import { ErrorCode } from '../wire/ErrorCode.js';
15
15
  import { FougereError } from '../wire/FougereError.js';
16
16
  import { Card, type SchemaView, type SchemaDescriptor } from '@fougere/schema';
17
17
  import { dynamicOperations } from '../entry/facade.js';
18
+ import { decoded } from '../dispatch/decoded.js';
18
19
 
19
20
  interface Route {
20
21
  frond: string;
@@ -103,13 +104,19 @@ export function createRemoteFacade(
103
104
  middlewaresFor: (address: string) => AppMiddleware[],
104
105
  ): Facade {
105
106
  const opFn = (op: string) => async (invocation: InvocationContext = Invocation.empty) => {
106
- const { frond, transport } = await router.route(entity);
107
+ const { frond, transport, schema } = await router.route(entity);
107
108
  const call: FrondCall = { frond, entity, op };
108
109
  const ctx: OperationContext = {
109
110
  entity, frond, operation: op, args: [], state: invocation.state, invocation,
110
111
  };
111
- return runMiddlewares(middlewaresFor(entity), ctx, () =>
112
+ const answer = await runMiddlewares(middlewaresFor(entity), ctx, () =>
112
113
  transport(call, ctx.invocation ?? invocation));
114
+
115
+ // The schema the card carried, put to work: a row crosses as data and comes back through
116
+ // the same codecs a local facade applies, so a placement does not decide what a caller
117
+ // holds. It is the ENTITY's — an op serving a narrower view is the far side's business,
118
+ // and what this card names is the shape it publishes.
119
+ return decoded(schema, answer);
113
120
  };
114
121
 
115
122
  return dynamicOperations(opFn) as Facade;
package/src/contract.ts CHANGED
@@ -29,6 +29,7 @@ export { MAX_BODY_BYTES } from './wire/SignedCall.js';
29
29
  // call log ignoring its own reader — has to be able to name it.
30
30
  export { RPC_ENTITY } from './wire/RpcAnswer.js';
31
31
  export type { CallPage } from './wire/CallPage.js';
32
+ export { pageOf, asPage, type Page } from './wire/Page.js';
32
33
  export type { CallRecord } from './wire/CallRecord.js';
33
34
  // The comparison of two cards, which a consumer runs about a producer — browser-safe on
34
35
  // purpose: a panel showing the drift holds only the two cards, never the app.
@@ -55,6 +56,7 @@ export type { Edge } from './wire/topology/Edge.js';
55
56
  export type { FrondPlacement } from './wire/topology/FrondPlacement.js';
56
57
  export type { TopologyReport } from './wire/topology/TopologyReport.js';
57
58
  export { assertIdentityCard } from './wire/card/IdentityCard.js';
59
+ export { decoded } from './dispatch/decoded.js';
58
60
 
59
61
  /** The key a class name is filed under — 'Post' → 'post'. */
60
62
  export { lowerFirst } from '@fougere/schema';
@@ -1,5 +1,6 @@
1
1
  import { Visibility, type Fields } from '@fougere/schema';
2
2
  import { preserveArrayProperties } from './ArrayResult.js';
3
+ import { asPage } from '../wire/Page.js';
3
4
 
4
5
  /** What one operation result may show. One of the two exits a validator watches. */
5
6
  export class OutputView {
@@ -15,6 +16,12 @@ export class OutputView {
15
16
  return preserveArrayProperties(result, result.map((item) => this.project(item)));
16
17
  }
17
18
 
19
+ // A paged op answers an envelope, so what a view applies to is its ROWS. Projecting the
20
+ // envelope itself let every field of every row through — `Visibility.encode` finds none of
21
+ // its own keys on `{ items, total }` and hands the object back.
22
+ const page = asPage(result, this.fields);
23
+ if (page) return { ...page, items: page.items.map((item) => this.project(item)) };
24
+
18
25
  if (typeof result !== 'object') return result;
19
26
 
20
27
  const record = result as Record<string, unknown>;
@@ -2,6 +2,7 @@ import { ErrorCode } from '../wire/ErrorCode.js';
2
2
  import { FougereError } from '../wire/FougereError.js';
3
3
  import { preserveArrayProperties } from './ArrayResult.js';
4
4
  import type { PresenterArgs } from './PresenterArgs.js';
5
+ import { asPage } from '../wire/Page.js';
5
6
 
6
7
  /** Adds a presenter's computed fields after output projection. */
7
8
  export class PresenterExecutor {
@@ -17,6 +18,12 @@ export class PresenterExecutor {
17
18
  return result;
18
19
  }
19
20
 
21
+ // A paged op answers an envelope, and what a computed field sits on is a ROW. Handed the
22
+ // envelope itself, a presenter added its fields to `{ items, total }` and every row came
23
+ // back untouched.
24
+ const page = asPage(result, Object.fromEntries(this.fieldNames.map((name) => [name, true])));
25
+ if (page) return { ...page, items: await this.present(page.items, args) as unknown[] };
26
+
20
27
  const rows = Array.isArray(result) ? result : [result];
21
28
  const values = new Map<string, unknown[]>();
22
29
  for (const name of this.fieldNames) {
@@ -0,0 +1,2 @@
1
+ /** What a facade puts back into an answer before handing it to its caller, read per operation. */
2
+ export type Received = (operation: string, answer: unknown) => unknown;
@@ -0,0 +1,37 @@
1
+ import { Visibility, type Fields, type SchemaView } from '@fougere/schema';
2
+ import { preserveArrayProperties } from './ArrayResult.js';
3
+ import { asPage } from '../wire/Page.js';
4
+
5
+ /**
6
+ * What a caller receives, read back into the shape its type promises — the dual of
7
+ * `OutputView.project`, applied on the other side of the call.
8
+ *
9
+ * `date-time` means a `Date` on both sides (`Boundary.forShape`), and only the outgoing half was
10
+ * ever applied: `Visibility.encode` turned `new Date(0)` into `"1970-01-01T00:00:00.000Z"` and
11
+ * nobody turned it back, so `Facade<PostHandler>` promised `createdAt: Date` and handed over a
12
+ * string — in this process as well as across a wire.
13
+ *
14
+ * Applied by the facade a caller HOLDS, never by the one that answers: a row leaves as data,
15
+ * which is what a Rust frond or a plain HTTP client reads, and the codecs are what this side
16
+ * knows how to put back. Measured 2026-09-18: 389 ns per row against the 1 277 ns encoding one
17
+ * already costs.
18
+ *
19
+ * Documented: [the gradient](https://fougere.dev/docs/infra/gradient).
20
+ */
21
+ export function decoded(schema: SchemaView | undefined, answer: unknown): unknown {
22
+ if (!schema || answer === null || answer === undefined) return answer;
23
+
24
+ const fields = schema.getFields() as Fields;
25
+ const visibility = Visibility.of(fields);
26
+ const row = (one: unknown) => (one !== null && typeof one === 'object' && !Array.isArray(one)
27
+ ? visibility.decode(one as Record<string, unknown>)
28
+ : one);
29
+
30
+ const page = asPage(answer, fields);
31
+ if (page) return { ...page, items: page.items.map(row) };
32
+
33
+ // An op annotating `ListResult<T>` still answers the array those properties ride on.
34
+ return Array.isArray(answer)
35
+ ? preserveArrayProperties(answer, answer.map(row))
36
+ : row(answer);
37
+ }
@@ -2,6 +2,7 @@ import { Call } from '../wire/Call.js';
2
2
 
3
3
  import { RouteAddress } from '../wire/RouteAddress.js';
4
4
  import type { DispatchPort } from '../dispatch/DispatchPort.js';
5
+ import type { Received } from '../dispatch/Received.js';
5
6
 
6
7
  type Operation = (...args: any[]) => unknown;
7
8
 
@@ -22,21 +23,32 @@ export function dynamicOperations(operation: (name: string) => Operation): Recor
22
23
  });
23
24
  }
24
25
 
25
- /** Turns facade method calls into canonical dispatches. */
26
+ /**
27
+ * Turns facade method calls into canonical dispatches.
28
+ *
29
+ * `received` is what this side puts back before handing the answer over: a row leaves as data
30
+ * and `date-time` means a `Date` on both sides. A facade built without one hands over what the
31
+ * wire carried, which is what a caller holding no schema can do.
32
+ */
26
33
  export function facadeOperations(
27
34
  dispatcher: DispatchPort,
28
35
  entity: string,
29
36
  operationNames?: Iterable<string>,
30
37
  surface?: string,
38
+ received?: Received,
31
39
  ): Record<string, Operation> {
32
- const operation = (name: string): Operation => (invocation) => dispatcher.dispatch(new Call(
33
- new RouteAddress({
34
- entity,
35
- operation: name,
36
- ...(surface !== undefined ? { surface } : {}),
37
- }),
38
- invocation,
39
- ));
40
+ const operation = (name: string): Operation => async (invocation) => {
41
+ const answer = await dispatcher.dispatch(new Call(
42
+ new RouteAddress({
43
+ entity,
44
+ operation: name,
45
+ ...(surface !== undefined ? { surface } : {}),
46
+ }),
47
+ invocation,
48
+ ));
49
+
50
+ return received ? received(name, answer) : answer;
51
+ };
40
52
 
41
53
  return operationNames
42
54
  ? Object.fromEntries([...operationNames].map((name) => [name, operation(name)]))
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export { JOURNAL, type Journal } from './dispatch/Journal.js';
49
49
  // them — or a test of one — has to be able to make one through the facade.
50
50
  export { DispatchEvent } from './dispatch/DispatchEvent.js';
51
51
  export type { CallPage, CallRecord } from './contract.js';
52
+ export { pageOf, asPage, type Page } from './contract.js';
52
53
  export { driftOf, agrees, explain, type CardDrift } from './contract.js';
53
54
  export type { OperationContract } from './wire/OperationContract.js';
54
55
  export type { OperationsMap } from './wire/OperationsMap.js';
@@ -6,6 +6,7 @@ import type { OperationContract } from '../wire/OperationContract.js';
6
6
  import { targetOf } from './prefab.js';
7
7
  import type { CrudOpName } from './CrudOpName.js';
8
8
  import type { CrudViews } from './CrudViews.js';
9
+ import { pageOf, type Page } from '../wire/Page.js';
9
10
  import type { CrudOps } from './CrudOps.js';
10
11
 
11
12
  /**
@@ -56,7 +57,7 @@ function crudOps(entity: SchemaView & { partial?: () => SchemaView }): Record<st
56
57
  output: entity, cardinality: 'page',
57
58
  binding: [{ name: 'options', source: { kind: 'query' }, optional: true }],
58
59
  signature: {
59
- name: 'list', returnType: returns(`ListResult<${name}>`, 'ListResult'),
60
+ name: 'list', returnType: returns(`Page<${name}>`, 'Page'),
60
61
  params: [{ name: 'options', type: { raw: 'ListOptions', name: 'ListOptions' }, optional: true }],
61
62
  },
62
63
  },
@@ -139,7 +140,7 @@ export function Crud<E extends EntityConstructor, V extends CrudViews | EntityCo
139
140
  this.storage = storage as Storage<T>;
140
141
  }
141
142
 
142
- async list(options?: ListOptions): Promise<ListResult<T>> { return this.storage.list(options) as Promise<ListResult<T>>; }
143
+ async list(options?: ListOptions): Promise<Page<T>> { return pageOf(await this.storage.list(options)); }
143
144
  async findById(id: string): Promise<T | undefined> { return this.storage.findById(id); }
144
145
  async create(input: Partial<T>): Promise<T> { return this.storage.create(input); }
145
146
  async update(id: string, input: Partial<T>): Promise<T> { return this.storage.update(id, input); }
@@ -1,7 +1,7 @@
1
1
  import type { CrudOpName } from './CrudOpName.js';
2
2
  import type { EntityConstructor } from '@fougere/schema';
3
3
  import type { ListOptions } from '../storage/ListOptions.js';
4
- import type { ListResult } from '../storage/ListResult.js';
4
+ import type { Page } from '../wire/Page.js';
5
5
  import type { Storage } from '../storage/Storage.js';
6
6
 
7
7
  /** The view an op emits, fabricated. */
@@ -16,7 +16,7 @@ type OutOf<V, K extends CrudOpName, T> =
16
16
  /** The five ops, typed from the entity and its views. */
17
17
  export interface CrudOps<T, V = {}> {
18
18
  storage: Storage<T>;
19
- list(options?: ListOptions, ...collected: never[]): Promise<ListResult<OutOf<V, 'list', T>>>;
19
+ list(options?: ListOptions, ...collected: never[]): Promise<Page<OutOf<V, 'list', T>>>;
20
20
  findById(id: string, ...collected: never[]): Promise<OutOf<V, 'findById', T> | undefined>;
21
21
  create(input: Partial<T>, ...collected: never[]): Promise<OutOf<V, 'create', T>>;
22
22
  update(id: string, input: Partial<T>, ...collected: never[]): Promise<OutOf<V, 'update', T>>;
@@ -27,7 +27,7 @@ export function Mirror<E extends EntityConstructor>(shape: E): MirrorConstructor
27
27
  written += await this.storage.upsertAll(page);
28
28
  }
29
29
 
30
- return { written, since, ms: Date.now() - started };
30
+ return { written, ...(since && { since: since.toISOString() }), ms: Date.now() - started };
31
31
  }
32
32
  }
33
33
 
@@ -2,8 +2,13 @@
2
2
  export interface Refreshed {
3
3
  /** Instances written, counting a replaced one once. */
4
4
  written: number;
5
- /** The age the pull was asked to start from — absent when it asked for everything. */
6
- since?: Date;
5
+ /**
6
+ * The age the pull was asked to start from, as an ISO string — absent when it asked for
7
+ * everything. A STRING because a refresh is an operation: what it answers leaves through a
8
+ * facade, where only data crosses, and a `Date` reaches a caller here and an ISO string
9
+ * behind `fronds:` off the same code.
10
+ */
11
+ since?: string;
7
12
  /** How long the whole pass took, pull included. */
8
13
  ms: number;
9
14
  }
@@ -1,4 +1,4 @@
1
- import { applyCreate, applyUpdate, Lifecycle, Role, type SchemaView } from '@fougere/schema';
1
+ import { applyCreate, applyUpdate, Role, type SchemaView } from '@fougere/schema';
2
2
  import { comparisonOf, comparisonsIn, type Comparison } from './Comparison.js';
3
3
  import type { Storage } from './Storage.js';
4
4
  import type { StorageFactory } from './StorageFactory.js';
@@ -46,19 +46,16 @@ export function storageOver(open: (entity: SchemaView, name: string) => Store):
46
46
  ? Object.fromEntries(Object.entries(values).filter(([key]) => selected.has(key)))
47
47
  : values);
48
48
 
49
- // Same contract as SQL: the key and the creation stamps survive an overwrite.
49
+ // Same contract as SQL: a row that is already there is UPDATED, so what the write leaves
50
+ // out stays where it was.
50
51
  // Named, and not reached through `this`: a caller may have wrapped these gestures,
51
52
  // and a derived one that goes back through the front facade is judged twice.
52
53
  const upsert = async (input: Partial<Record<string, unknown>>): Promise<Values> => {
53
- const values = applyCreate(fields, applyUpdate(fields, input));
54
- const id = values[pk] as string | undefined;
54
+ const created = applyCreate(fields, applyUpdate(fields, input));
55
+ const id = created[pk] as string | undefined;
55
56
  if (id === undefined) throw new Error(`${name}.upsert(): no \`${pk}\` — an upsert needs the key it writes at.`);
56
57
  const previous = await store.get(keyOf(id));
57
- if (previous) {
58
- for (const [key, field] of Object.entries(fields)) {
59
- if (key === pk || Lifecycle.of(field).stampedOnce) values[key] = previous[key];
60
- }
61
- }
58
+ const values = previous ? { ...previous, ...applyUpdate(fields, input) } : created;
62
59
  await store.set(keyOf(id), values);
63
60
  return pick(values);
64
61
  };
@@ -1,7 +1,16 @@
1
1
  import type { OperationContext } from './OperationContext.js';
2
2
  import type { AppNext } from './AppNext.js';
3
3
 
4
- export type AppMiddleware = (ctx: OperationContext, next: AppNext) => Promise<unknown>;
4
+ /**
5
+ * A middleware answers what the chain answered — the same TYPE, whatever it does with the value.
6
+ *
7
+ * `T` is the whole rule and nothing enforces it at runtime: a middleware is handed a type it
8
+ * cannot name, so the only value of that type it can produce is the one `next()` gave it. It may
9
+ * observe it, log it, replace it with another of the same shape, or refuse by throwing; it may
10
+ * not wrap it in `{ data, meta }` nor invent one, because a caller reads the handler's signature
11
+ * and nothing tells that signature a middleware stood in the way.
12
+ */
13
+ export type AppMiddleware = <T>(ctx: OperationContext, next: AppNext<T>) => Promise<T>;
5
14
 
6
15
  // ── Runner ──────────────────────────────────────
7
16
 
@@ -1 +1,2 @@
1
- export type AppNext = () => Promise<unknown>;
1
+ /** What a middleware holds: the rest of the chain, and the answer it will hand back. */
2
+ export type AppNext<T = unknown> = () => Promise<T>;
@@ -10,10 +10,22 @@ type Served = keyof FougereOperations & string;
10
10
  */
11
11
  type AddressIn<Key> = Key extends `${infer Address}.${string}` ? Address : never;
12
12
 
13
- /** The facade built in front of a handler — the framework's second port, after `Storage`. */
13
+ /**
14
+ * The facade built in front of a handler — the framework's second port, after `Storage`.
15
+ *
16
+ * What the handler knows is WHICH operations exist and what each one answers. The two ends are
17
+ * the port's own: an invocation goes in where the handler takes positional arguments, and a
18
+ * promise comes back where the handler may answer a bare value. A facade is a crossing, and a
19
+ * crossing is awaited before any transport — the dispatch resolves a route, runs the middlewares
20
+ * and awaits the collectors, so `readLocation(): string` was typed as answering now and never did.
21
+ *
22
+ * `Awaited` because a promise does not stack: `Promise.resolve(p)` IS `p`, so writing
23
+ * `Promise<R>` over an async handler would describe a `Promise<Promise<Post>>` that no value can
24
+ * have — `.then` would hand its callback a promise the runtime never delivers.
25
+ */
14
26
  export type Facade<T> = {
15
27
  [K in keyof T]: T[K] extends (...args: never[]) => infer R
16
- ? (invocation?: InvocationContext) => R
28
+ ? (invocation?: InvocationContext) => Promise<Awaited<R>>
17
29
  : never;
18
30
  };
19
31
 
@@ -20,7 +20,7 @@ export class FougereError<Code extends ErrorCode = ErrorCode> extends Error {
20
20
 
21
21
  constructor(options: FougereErrorOptions<Code>) {
22
22
  super(options.message, { cause: options.cause });
23
- this.name = new.target.name;
23
+ this.name = 'FougereError';
24
24
  this.code = options.code;
25
25
  this.entity = options.entity;
26
26
  this.operation = options.operation;
@@ -36,7 +36,7 @@ export function cardinalityOf(type: TypeRef | undefined): OperationContract['car
36
36
  if (!type) return undefined;
37
37
  const inner = type.name === 'Promise' ? type.generics?.[0] : type;
38
38
  if (!inner) return 'none';
39
- if (inner.name === 'ListResult') return 'page';
39
+ if (inner.name === 'Page' || inner.name === 'ListResult') return 'page';
40
40
  if (inner.array) return 'many';
41
41
  if (PRIMITIVE_RETURNS.has(inner.name)) return 'none';
42
42
  return inner.nullable || inner.undefined ? 'maybe' : 'one';
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What a paged operation answers — the rows, and what the storage said about the rest of them.
3
+ *
4
+ * An ENVELOPE rather than the array `Storage.list` hands back: that one carries `total` and
5
+ * `hasMore` as properties OF the array, and `JSON.stringify` keeps only the indices. Measured
6
+ * 2026-09-18 — `page.total` is 42 in this process and `undefined` behind `fronds:`, off the same
7
+ * code. What crosses has to be named, so it is a field.
8
+ *
9
+ * `Storage.list` keeps its array: it answers inside a frond and never crosses.
10
+ */
11
+ export interface Page<Row> {
12
+ items: Row[];
13
+ /** Only when the caller asked to count. */
14
+ total?: number;
15
+ hasMore?: boolean;
16
+ endCursor?: string;
17
+ }
18
+
19
+ /**
20
+ * The array `Storage.list` answers, read as the page an operation hands over.
21
+ *
22
+ * A storage handed in by a host may answer the envelope already — `storageFactory` is its door,
23
+ * and nothing there is obliged to subclass an array. Taken as it comes in that case.
24
+ */
25
+ export function pageOf<Row>(rows: Page<Row> | readonly Row[]): Page<Row> {
26
+ if (!Array.isArray(rows)) return rows as Page<Row>;
27
+
28
+ const { total, hasMore, endCursor } = rows as unknown as Omit<Page<Row>, 'items'>;
29
+
30
+ return {
31
+ items: [...rows],
32
+ ...(total !== undefined && { total }),
33
+ ...(hasMore !== undefined && { hasMore }),
34
+ ...(endCursor !== undefined && { endCursor }),
35
+ };
36
+ }
37
+
38
+ /**
39
+ * The value read as a page, or nothing.
40
+ *
41
+ * Read from the FORM, and arbitrated by the schema: an entity legally declaring a field named
42
+ * `items` answers a row, not a page, and it is the only thing that could tell the two apart.
43
+ */
44
+ export function asPage(value: unknown, fields: Record<string, unknown>): Page<unknown> | undefined {
45
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
46
+ if ('items' in fields) return undefined;
47
+ const page = value as Page<unknown>;
48
+
49
+ return Array.isArray(page.items) ? page : undefined;
50
+ }
@@ -10,4 +10,11 @@ export interface Signature {
10
10
  inherited?: boolean;
11
11
  /** The operation in words — the first sentence of the method's doc comment. */
12
12
  description?: string;
13
+ /**
14
+ * The first thing in the return type that is not data — its path, and the type sitting there.
15
+ *
16
+ * Written whenever a checker read the signature, and judged only where nothing converts the
17
+ * output: what a declared entity answers is encoded by its fields.
18
+ */
19
+ notData?: string;
13
20
  }