@fougere/core 0.3.0-alpha.0 → 0.5.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 (243) hide show
  1. package/README.md +1 -1
  2. package/dist/boot/Emissions.js +1 -1
  3. package/dist/boot/Emissions.js.map +1 -1
  4. package/dist/boot/HandlerFacade.js +2 -2
  5. package/dist/boot/HandlerFacade.js.map +1 -1
  6. package/dist/boot/apply.d.ts.map +1 -1
  7. package/dist/boot/apply.js +2 -12
  8. package/dist/boot/apply.js.map +1 -1
  9. package/dist/boot/binding.d.ts +1 -1
  10. package/dist/boot/binding.d.ts.map +1 -1
  11. package/dist/boot/binding.js +4 -4
  12. package/dist/boot/binding.js.map +1 -1
  13. package/dist/boot/boot.d.ts +1 -1
  14. package/dist/boot/boot.d.ts.map +1 -1
  15. package/dist/boot/bootstrap.d.ts.map +1 -1
  16. package/dist/boot/bootstrap.js +26 -17
  17. package/dist/boot/bootstrap.js.map +1 -1
  18. package/dist/boot/frame.d.ts +1 -1
  19. package/dist/boot/frame.d.ts.map +1 -1
  20. package/dist/boot/frame.js +5 -14
  21. package/dist/boot/frame.js.map +1 -1
  22. package/dist/boot/hosted.d.ts +29 -0
  23. package/dist/boot/hosted.d.ts.map +1 -0
  24. package/dist/boot/hosted.js +38 -0
  25. package/dist/boot/hosted.js.map +1 -0
  26. package/dist/boot/ownership.d.ts +0 -3
  27. package/dist/boot/ownership.d.ts.map +1 -1
  28. package/dist/boot/ownership.js +5 -5
  29. package/dist/boot/ownership.js.map +1 -1
  30. package/dist/boot/remote.d.ts +1 -1
  31. package/dist/boot/remote.d.ts.map +1 -1
  32. package/dist/boot/remote.js +3 -3
  33. package/dist/boot/remote.js.map +1 -1
  34. package/dist/boot/statement-drift.d.ts +24 -0
  35. package/dist/boot/statement-drift.d.ts.map +1 -0
  36. package/dist/boot/statement-drift.js +38 -0
  37. package/dist/boot/statement-drift.js.map +1 -0
  38. package/dist/boot/together.d.ts.map +1 -1
  39. package/dist/boot/together.js +6 -6
  40. package/dist/boot/together.js.map +1 -1
  41. package/dist/boot/types.d.ts +17 -4
  42. package/dist/boot/types.d.ts.map +1 -1
  43. package/dist/contract/CallLog.d.ts +57 -0
  44. package/dist/contract/CallLog.d.ts.map +1 -0
  45. package/dist/contract/CallLog.js +2 -0
  46. package/dist/contract/CallLog.js.map +1 -0
  47. package/dist/contract.d.ts +9 -4
  48. package/dist/contract.d.ts.map +1 -1
  49. package/dist/contract.js +12 -5
  50. package/dist/contract.js.map +1 -1
  51. package/dist/dispatch/DispatchLifecycle.d.ts +9 -1
  52. package/dist/dispatch/DispatchLifecycle.d.ts.map +1 -1
  53. package/dist/dispatch/DispatchLifecycle.js +17 -2
  54. package/dist/dispatch/DispatchLifecycle.js.map +1 -1
  55. package/dist/dispatch/InFlight.d.ts +0 -1
  56. package/dist/dispatch/InFlight.d.ts.map +1 -1
  57. package/dist/dispatch/InFlight.js +0 -3
  58. package/dist/dispatch/InFlight.js.map +1 -1
  59. package/dist/dispatch/InputValidator.d.ts.map +1 -1
  60. package/dist/dispatch/InputValidator.js +3 -2
  61. package/dist/dispatch/InputValidator.js.map +1 -1
  62. package/dist/dispatch/OutputProjector.js +2 -2
  63. package/dist/dispatch/OutputProjector.js.map +1 -1
  64. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  65. package/dist/dispatch/StorageGuard.js +2 -2
  66. package/dist/dispatch/StorageGuard.js.map +1 -1
  67. package/dist/effective-operation.d.ts.map +1 -1
  68. package/dist/effective-operation.js +8 -5
  69. package/dist/effective-operation.js.map +1 -1
  70. package/dist/emit.d.ts +2 -2
  71. package/dist/emit.d.ts.map +1 -1
  72. package/dist/emit.js +2 -2
  73. package/dist/emit.js.map +1 -1
  74. package/dist/identity.js +1 -1
  75. package/dist/identity.js.map +1 -1
  76. package/dist/imports.d.ts +1 -1
  77. package/dist/imports.d.ts.map +1 -1
  78. package/dist/imports.js.map +1 -1
  79. package/dist/index.d.ts +8 -2
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +12 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/node.d.ts +2 -0
  84. package/dist/node.d.ts.map +1 -1
  85. package/dist/node.js +2 -0
  86. package/dist/node.js.map +1 -1
  87. package/dist/orm.d.ts.map +1 -1
  88. package/dist/orm.js +4 -4
  89. package/dist/orm.js.map +1 -1
  90. package/dist/prefab/collector.js +2 -2
  91. package/dist/prefab/collector.js.map +1 -1
  92. package/dist/prefab/mirror.d.ts.map +1 -1
  93. package/dist/prefab/mirror.js +2 -2
  94. package/dist/prefab/mirror.js.map +1 -1
  95. package/dist/prefab/presenter.d.ts.map +1 -1
  96. package/dist/prefab/presenter.js +2 -2
  97. package/dist/prefab/presenter.js.map +1 -1
  98. package/dist/prefab/repository.d.ts.map +1 -1
  99. package/dist/prefab/repository.js +2 -2
  100. package/dist/prefab/repository.js.map +1 -1
  101. package/dist/scan/bundling.d.ts +14 -0
  102. package/dist/scan/bundling.d.ts.map +1 -0
  103. package/dist/scan/bundling.js +26 -0
  104. package/dist/scan/bundling.js.map +1 -0
  105. package/dist/scan/declare.d.ts +71 -0
  106. package/dist/scan/declare.d.ts.map +1 -0
  107. package/dist/scan/declare.js +125 -0
  108. package/dist/scan/declare.js.map +1 -0
  109. package/dist/scan/emit.d.ts.map +1 -1
  110. package/dist/scan/emit.js +3 -2
  111. package/dist/scan/emit.js.map +1 -1
  112. package/dist/scan/handler-parser.d.ts +2 -0
  113. package/dist/scan/handler-parser.d.ts.map +1 -1
  114. package/dist/scan/handler-parser.js +52 -13
  115. package/dist/scan/handler-parser.js.map +1 -1
  116. package/dist/scan/scanner.d.ts.map +1 -1
  117. package/dist/scan/scanner.js +37 -11
  118. package/dist/scan/scanner.js.map +1 -1
  119. package/dist/scan/statement.d.ts +4 -0
  120. package/dist/scan/statement.d.ts.map +1 -0
  121. package/dist/scan/statement.js +112 -0
  122. package/dist/scan/statement.js.map +1 -0
  123. package/dist/verify.js +2 -2
  124. package/dist/verify.js.map +1 -1
  125. package/dist/wire/call.d.ts +19 -8
  126. package/dist/wire/call.d.ts.map +1 -1
  127. package/dist/wire/call.js +17 -6
  128. package/dist/wire/call.js.map +1 -1
  129. package/dist/wire/drift.d.ts +55 -0
  130. package/dist/wire/drift.d.ts.map +1 -0
  131. package/dist/wire/drift.js +121 -0
  132. package/dist/wire/drift.js.map +1 -0
  133. package/dist/wire/middleware.d.ts +1 -1
  134. package/dist/wire/middleware.d.ts.map +1 -1
  135. package/package.json +6 -4
  136. package/src/boot/AppLifecycle.ts +50 -0
  137. package/src/boot/Emissions.ts +263 -0
  138. package/src/boot/HandlerFacade.ts +242 -0
  139. package/src/boot/ambient-port.ts +41 -0
  140. package/src/boot/ambient.als.ts +43 -0
  141. package/src/boot/ambient.queue.ts +75 -0
  142. package/src/boot/apply.ts +47 -0
  143. package/src/boot/auth.ts +55 -0
  144. package/src/boot/binding.ts +182 -0
  145. package/src/boot/boot.ts +125 -0
  146. package/src/boot/bootstrap.ts +847 -0
  147. package/src/boot/frame.ts +210 -0
  148. package/src/boot/hosted.ts +77 -0
  149. package/src/boot/ownership.ts +137 -0
  150. package/src/boot/ports.ts +66 -0
  151. package/src/boot/remote.ts +179 -0
  152. package/src/boot/seed.ts +158 -0
  153. package/src/boot/statement-drift.ts +60 -0
  154. package/src/boot/together.ts +215 -0
  155. package/src/boot/types.ts +274 -0
  156. package/src/builtins/config.ts +19 -0
  157. package/src/builtins/logger.ts +175 -0
  158. package/src/config-loader.ts +146 -0
  159. package/src/contract/Call.ts +18 -0
  160. package/src/contract/CallLog.ts +55 -0
  161. package/src/contract/Invocation.ts +72 -0
  162. package/src/contract/RouteAddress.ts +49 -0
  163. package/src/contract.ts +83 -0
  164. package/src/crypto/encoding.ts +59 -0
  165. package/src/crypto/node.ts +30 -0
  166. package/src/crypto/port.ts +45 -0
  167. package/src/crypto/webcrypto.ts +55 -0
  168. package/src/define.ts +10 -0
  169. package/src/dispatch/ArgumentResolver.ts +17 -0
  170. package/src/dispatch/ArrayResult.ts +10 -0
  171. package/src/dispatch/DispatchEvent.ts +38 -0
  172. package/src/dispatch/DispatchLifecycle.ts +43 -0
  173. package/src/dispatch/DispatchPort.ts +6 -0
  174. package/src/dispatch/Dispatcher.ts +48 -0
  175. package/src/dispatch/InFlight.ts +45 -0
  176. package/src/dispatch/InputValidator.ts +28 -0
  177. package/src/dispatch/LocalRoutePolicy.ts +34 -0
  178. package/src/dispatch/OperationExecutor.ts +60 -0
  179. package/src/dispatch/OperationRoute.ts +18 -0
  180. package/src/dispatch/OutputProjector.ts +32 -0
  181. package/src/dispatch/OutputView.ts +9 -0
  182. package/src/dispatch/PresenterArgumentResolver.ts +25 -0
  183. package/src/dispatch/PresenterExecutor.ts +55 -0
  184. package/src/dispatch/RemoteRouteResolver.ts +21 -0
  185. package/src/dispatch/Route.ts +11 -0
  186. package/src/dispatch/RouteNotFoundError.ts +29 -0
  187. package/src/dispatch/RoutePolicy.ts +8 -0
  188. package/src/dispatch/RouteRegistry.ts +73 -0
  189. package/src/dispatch/RouteResolver.ts +7 -0
  190. package/src/dispatch/StorageGuard.ts +71 -0
  191. package/src/effective-operation.ts +601 -0
  192. package/src/emit.ts +90 -0
  193. package/src/entry/DynamicFacade.ts +28 -0
  194. package/src/entry/FacadeEntry.ts +32 -0
  195. package/src/entry/TransportEntry.ts +30 -0
  196. package/src/frond-config.ts +138 -0
  197. package/src/graph.ts +180 -0
  198. package/src/identity-keys.ts +43 -0
  199. package/src/identity.ts +235 -0
  200. package/src/imports.ts +123 -0
  201. package/src/index.ts +120 -0
  202. package/src/loader.ts +41 -0
  203. package/src/node.ts +30 -0
  204. package/src/orm.ts +252 -0
  205. package/src/prefab/collector.ts +36 -0
  206. package/src/prefab/crud.ts +188 -0
  207. package/src/prefab/mirror.ts +122 -0
  208. package/src/prefab/prefab.ts +46 -0
  209. package/src/prefab/presenter.ts +63 -0
  210. package/src/prefab/repository.ts +170 -0
  211. package/src/scan/Fronds.ts +73 -0
  212. package/src/scan/bundling.ts +25 -0
  213. package/src/scan/conventions.ts +122 -0
  214. package/src/scan/declare.ts +188 -0
  215. package/src/scan/emit.ts +231 -0
  216. package/src/scan/frond.ts +235 -0
  217. package/src/scan/handler-parser.ts +817 -0
  218. package/src/scan/scanner.ts +757 -0
  219. package/src/scan/statement.ts +126 -0
  220. package/src/verify.ts +193 -0
  221. package/src/wire/call.ts +381 -0
  222. package/src/wire/drift.ts +131 -0
  223. package/src/wire/errors.ts +124 -0
  224. package/src/wire/http-error.ts +77 -0
  225. package/src/wire/loggerMiddleware.ts +29 -0
  226. package/src/wire/middleware.ts +47 -0
  227. package/src/wire/operation.ts +258 -0
  228. package/dist/boot/Lifecycle.d.ts +0 -4
  229. package/dist/boot/Lifecycle.d.ts.map +0 -1
  230. package/dist/boot/Lifecycle.js +0 -3
  231. package/dist/boot/Lifecycle.js.map +0 -1
  232. package/dist/boot/egress.d.ts +0 -8
  233. package/dist/boot/egress.d.ts.map +0 -1
  234. package/dist/boot/egress.js +0 -11
  235. package/dist/boot/egress.js.map +0 -1
  236. package/dist/scan/scan-cache.d.ts +0 -16
  237. package/dist/scan/scan-cache.d.ts.map +0 -1
  238. package/dist/scan/scan-cache.js +0 -99
  239. package/dist/scan/scan-cache.js.map +0 -1
  240. package/dist/wire/invocation.d.ts +0 -4
  241. package/dist/wire/invocation.d.ts.map +0 -1
  242. package/dist/wire/invocation.js +0 -3
  243. package/dist/wire/invocation.js.map +0 -1
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Call contract — a frond call is a value.
3
+ *
4
+ * (frond, entity, op, invocation) → result, or a thrown FougereError.
5
+ * A transport is a function that executes this call elsewhere; the local
6
+ * runner below is the reference realization. Transports move the value,
7
+ * they never reshape it.
8
+ */
9
+ import { Card, type SchemaDescriptor } from '@fougere/schema';
10
+ import { factsAnnouncedBy } from '../emit.js';
11
+ import type { InvocationContext } from '../contract/Invocation.js';
12
+ import { FougereError, ErrorCode } from './errors.js';
13
+ import type { App } from '../boot/types.js';
14
+ import { createTransportEntry } from '../entry/TransportEntry.js';
15
+
16
+ /** Target of a call — which façade operation, wherever it lives. */
17
+ export interface FrondCall {
18
+ /** Frond name, when the caller knows it. Routing hint only. */
19
+ frond?: string;
20
+ /** Entity registration key (e.g. 'product'). */
21
+ entity: string;
22
+ /** Façade operation name (e.g. 'findById', 'search'). */
23
+ op: string;
24
+ }
25
+
26
+ /** A transport executes a call somewhere else. Failures surface as thrown FougereError. */
27
+ export type Transport = (call: FrondCall, invocation: InvocationContext) => Promise<unknown>;
28
+
29
+ /** Reserved namespace — calls the runner answers itself, never a façade. */
30
+ /**
31
+ * What a receiver accepts before it stops reading a body.
32
+ *
33
+ * Stated once because it was stated FOUR times — `transport/http/policy.ts` said so in its
34
+ * own comment ("spelled in four packages") while three doors kept their own literal, so
35
+ * raising the cap here used to leave three of them refusing at the old one.
36
+ *
37
+ * `@fougere/http` keeps a fourth copy on purpose: it declares no Fougere dependency at all,
38
+ * and giving a leaf port a dependency on the kernel to share a number is the wrong trade.
39
+ */
40
+ export const MAX_BODY_BYTES = 1024 * 1024;
41
+
42
+ export const RPC_ENTITY = 'rpc';
43
+
44
+ /**
45
+ * What an `rpc` op answers — the door for what the app says about ITSELF, never about a row.
46
+ *
47
+ * `discover` is core's own, registered like any other (`bootstrap.ts`): a package declares
48
+ * its reading beside it, and an app that installed none serves none.
49
+ */
50
+ export type RpcAnswer = (invocation: InvocationContext, surface?: string) => unknown;
51
+
52
+ /**
53
+ * Everything a caller's envelope covers — the call as the sender meant it.
54
+ *
55
+ * Here and not beside the signing code because it is a CONTRACT: the sender binds it,
56
+ * the receiver re-presents what arrived, and a frond written in another language reads
57
+ * this shape without reading our crypto. `body` is bound by digest (arbitrary, possibly
58
+ * large), the rest by value — which is what keeps a canonical-JSON dependency out.
59
+ */
60
+ export interface SignedCall {
61
+ entity: string;
62
+ op: string;
63
+ params?: Record<string, unknown>;
64
+ query?: Record<string, unknown>;
65
+ body?: unknown;
66
+ state?: Record<string, unknown>;
67
+ }
68
+
69
+ /**
70
+ * One operation, as a stranger meets it — its name, what it is for, what it takes
71
+ * and whether it reads or writes. Everything here already existed on the contract;
72
+ * it just never left the process.
73
+ */
74
+ export interface CardOp {
75
+ name: string;
76
+ /** The author's own doc sentence, when the method carries one. */
77
+ description?: string;
78
+ /** JSON Schema of what it accepts, when the contract names a view. */
79
+ input?: SchemaDescriptor;
80
+ /** JSON Schema of what it emits, when the contract names one. */
81
+ output?: SchemaDescriptor;
82
+ /** `query` reads, `command` writes — the same call REST turns into GET vs POST. */
83
+ kind: 'query' | 'command';
84
+ /**
85
+ * How much `output` describes: one row, maybe one, many, a page, or nothing shaped.
86
+ * `output` alone is the shape of a ROW, so a consumer generating a signature from
87
+ * this card would otherwise have to guess — and `list` returning a page rather than
88
+ * an array is exactly the guess it would get wrong.
89
+ */
90
+ cardinality?: 'one' | 'maybe' | 'many' | 'page' | 'none';
91
+ }
92
+
93
+ /**
94
+ * What an app hosts — the wire projection of its scanned fronds.
95
+ *
96
+ * Two lists per frond, and they are duals: **what you may call**, and **what you will
97
+ * receive**. A door is entered from outside; a fact leaves on its own.
98
+ *
99
+ * `doors` was called `entities` and the name lied: an entry is an ADDRESS, and the shape
100
+ * behind it is optional (`facadeKeyOf` builds its key from a handler's name, which need not
101
+ * be an entity's). The lie cost something real — `fougere sync` demanded a descriptor on
102
+ * every entry and refused whole cards over a health check.
103
+ */
104
+ export interface IdentityCard {
105
+ fronds: {
106
+ name: string;
107
+ doors: {
108
+ name: string;
109
+ ops: CardOp[];
110
+ /**
111
+ * The shape stored under this name — **absent when nothing is**. A handler may
112
+ * carry no entity (`bootstrap`: *pointing at nothing is legal*), and a door with
113
+ * no rows behind it is ordinary: a health check, a computation, a search across
114
+ * several shapes. A reader that needs the shape must say what it does without one.
115
+ */
116
+ schema?: SchemaDescriptor;
117
+ }[];
118
+ /**
119
+ * The facts this frond ANNOUNCES — one entry per `Emit<T>` its handlers inject.
120
+ *
121
+ * They carry no operation, which is why they cannot ride in `doors`: the rule there is
122
+ * *hosting means answering*, and an entity with no façade is excluded on purpose (it
123
+ * would publish the auth tables to anyone who asks). A fact is the opposite case —
124
+ * someone WROTE that it leaves, so publishing its shape is honouring a statement, not
125
+ * leaking one.
126
+ *
127
+ * Without this, a fact stopped at the repository boundary: `remotes:` gives the
128
+ * location, colocation gives the contract, and across two repositories there is no
129
+ * colocation. The subscriber had to hand-copy the emitter's declaration.
130
+ *
131
+ * `schema` is absent when the announced type is not a declared entity — legal (nothing
132
+ * requires a fact to be one) and worth saying rather than hiding: the name travels, the
133
+ * shape does not, and a consumer can see exactly that.
134
+ */
135
+ facts: { name: string; schema?: SchemaDescriptor }[];
136
+ }[];
137
+ }
138
+
139
+ /** A frond this process knows about, and whether it runs here. */
140
+ export interface FrondPlacement {
141
+ frond: string;
142
+ placement: 'local' | 'remote';
143
+ entities: number;
144
+ doors: number;
145
+ }
146
+
147
+ /** One frond calling another — an edge of the graph, counted where the call was made. */
148
+ export interface Edge {
149
+ from: string;
150
+ to: string;
151
+ count: number;
152
+ errors: number;
153
+ }
154
+
155
+ /**
156
+ * The shape of the system as ONE process discovered it — the answer to `rpc.topology`.
157
+ *
158
+ * Here and not in the package that produces it, for the reason `SignedCall` is here: it
159
+ * crosses a process boundary, so a reader consumes it without depending on the producer.
160
+ * `@fougere/observability` fills it; a panel reads it; neither imports the other.
161
+ *
162
+ * Nothing in it is declared — a frond is `local` because this process scanned it, `remote`
163
+ * because it answered a call nobody here hosts. `remotes:` states an intent, and the two
164
+ * disagree exactly when something is misconfigured.
165
+ */
166
+ export interface TopologyReport {
167
+ /** When this process started counting — an edge count is read against it. */
168
+ since: number;
169
+ /** Calls running right now: the one signal a static shape cannot carry. */
170
+ active: number;
171
+ fronds: FrondPlacement[];
172
+ edges: Edge[];
173
+ }
174
+
175
+ /**
176
+ * The shape a card must have to be walked — `fronds`, and each frond's `doors`.
177
+ *
178
+ * A card crosses a process boundary, so it is judged like anything else that does.
179
+ * The check lives beside the type because two readers walk it: the boot indexes a
180
+ * remote's doors, and `fougere sync` writes classes from them. `TypeError:
181
+ * card.fronds is not iterable` was what a malformed card produced at the boot,
182
+ * naming neither the remote nor the address.
183
+ *
184
+ * The descriptor behind a door is NOT checked here — `Card.toSchema` refuses it, and
185
+ * only where one is consumed.
186
+ */
187
+ export function assertIdentityCard(value: unknown, source: string): IdentityCard {
188
+ const card = value as IdentityCard | undefined;
189
+ const fronds = Array.isArray(card?.fronds) ? card.fronds : undefined;
190
+ if (!fronds) throw cardRefusal(source, 'no fronds array');
191
+ for (const frond of fronds) {
192
+ if (!frond || typeof frond.name !== 'string') throw cardRefusal(source, 'a frond with no name');
193
+ if (!Array.isArray(frond.doors)) throw cardRefusal(source, `frond '${frond.name}' has no valid doors array`);
194
+ }
195
+ return card as IdentityCard;
196
+ }
197
+
198
+ function cardRefusal(source: string, what: string): FougereError {
199
+ return new FougereError({
200
+ code: ErrorCode.INTERNAL_ERROR,
201
+ message:
202
+ `${source} answered an invalid identity card: ${what}.\n`
203
+ + ` A card is what tells this process what the other one hosts, so nothing can be routed from it.\n`
204
+ + ` - Check that the address serves a Fougere app, and that its version still speaks this card.`,
205
+ });
206
+ }
207
+
208
+ /** A façade as the runtime holds it: op names to functions, nothing typed about them. */
209
+ type AnyFacade = Record<string, (invocation?: InvocationContext) => Promise<unknown>>;
210
+
211
+ /**
212
+ * The door built in front of a handler — the framework's second port, after `EntityOrm`.
213
+ *
214
+ * `Facade<PostHandler>` is what a neighbouring frond injects. It names what ARRIVES
215
+ * rather than what is written: never the handler (nobody injects it, and its methods take
216
+ * positional arguments), but the door, whose every op takes the invocation and whose
217
+ * implementation is the local façade or a doublure. A signature therefore says nothing
218
+ * about where the other frond runs, which is the whole point.
219
+ *
220
+ * `keyof T` is the right set by construction, not by approximation: the scan skips
221
+ * `private` and `protected` (`handler-parser.ts`) because "TypeScript already has the
222
+ * word for it", so a handler's public methods ARE its operations — and `keyof` excludes
223
+ * the rest for the same reason.
224
+ */
225
+ export type Facade<T> = {
226
+ [K in keyof T]: T[K] extends (...args: never[]) => infer R
227
+ ? (invocation?: InvocationContext) => R
228
+ : never;
229
+ };
230
+
231
+ /**
232
+ * The container key of a façade — THE one place that spells the format.
233
+ *
234
+ * A surface is a named audience, and it is a key: `handlers/public/PostHandler.ts`
235
+ * registers `public:postHandler` next to the default `postHandler`. The default
236
+ * surface is the empty key, so callers that know nothing about surfaces keep
237
+ * designating exactly what they designated before.
238
+ *
239
+ * Nobody outside this package builds this string: adapters ask `app.facadeFor()`,
240
+ * which is why an adapter can stay structurally typed and core-free.
241
+ */
242
+ export function facadeKeyOf(entityName: string, surface?: string): string {
243
+ return surface ? `${surface}:${entityName}Handler` : `${entityName}Handler`;
244
+ }
245
+
246
+ /**
247
+ * Where the contracts behind a façade live — the dual of `facadeKeyOf`.
248
+ *
249
+ * The façade answers calls; it does not say what it answers. That sentence used
250
+ * to be true only inside `buildFacade`, so anything asking the app what it hosts
251
+ * got bare names. A door and its terms are registered together, under the same
252
+ * audience.
253
+ */
254
+ export function contractsKeyOf(entityName: string, surface?: string): string {
255
+ return `${facadeKeyOf(entityName, surface)}:contracts`;
256
+ }
257
+
258
+ /**
259
+ * Serialize what the app hosts, for one audience. Served on `rpc.discover`,
260
+ * cached by callers.
261
+ *
262
+ * Hosting means answering: an entity with no façade declares no operation, so
263
+ * it is not among the DOORS — publishing its shape would hand an anonymous caller
264
+ * the structure of tables it can never reach (the auth tables, typically), and
265
+ * `fougere sync` would rebuild a schema with nothing to call on it.
266
+ *
267
+ * `facts` is the one thing that shape-without-a-door rule does not cover, and the
268
+ * reason is the opposite of a leak: an entity nobody exposed is silent, whereas a
269
+ * fact was explicitly declared to leave.
270
+ *
271
+ * `surface` makes that sentence audience-aware rather than app-wide: a door
272
+ * answers with what IT serves. Nothing falls back to the full façade — under a
273
+ * named surface, an entity with no façade of its own is simply not there.
274
+ */
275
+ export function identityCardOf(app: App, surface?: string): IdentityCard {
276
+ const declared = app.fronds.schemas();
277
+
278
+ return {
279
+ fronds: app.fronds.map((frond) => {
280
+ // What the frond answers to, not what it stores. This walked `frond.entities`, so a
281
+ // handler carrying no entity — a health check, a search across shapes — was built,
282
+ // served, and absent from the card: `sync` could not generate its door and a remote
283
+ // consumer had no way to know it existed. The boot has said "pointing at nothing is
284
+ // legal" since handlers became the subject; the card had not caught up.
285
+ const byEntity = new Map(frond.entities.map((entity) => [entity.name, entity]));
286
+ const addresses = [...new Set([
287
+ ...frond.entities.map((entity) => entity.name),
288
+ ...frond.handlers.map((handler) => handler.address),
289
+ ])];
290
+
291
+ return {
292
+ name: frond.name,
293
+ doors: addresses.flatMap((address) => {
294
+ const ops = facadeOps(app, address, surface);
295
+ if (ops.length === 0) return [];
296
+ const entity = byEntity.get(address);
297
+ return [{
298
+ name: address,
299
+ ops,
300
+ // Absent when nothing of that name is stored. A door is still a door.
301
+ ...(entity ? { schema: Card.fromSchema(entity.entityClass, address).descriptor } : {}),
302
+ }];
303
+ }),
304
+ /**
305
+ * What leaves on its own — the same list on every surface, deliberately.
306
+ *
307
+ * A door has an audience; a fact does not. `Emit<T>` names a subject and *the
308
+ * number of readers is not the emitter's business* (`emit.ts`), so narrowing this
309
+ * by `surface` would invent an axis the primitive refuses. The consequence is
310
+ * stated rather than hidden: a fact's SHAPE is readable by anyone who can read the
311
+ * card at all. What must not be published must not be announced.
312
+ */
313
+ facts: factsAnnouncedBy(frond.handlers).map((name) => {
314
+ const entityClass = declared.get(name);
315
+ return { name, ...(entityClass ? { schema: Card.fromSchema(entityClass, name).descriptor } : {}) };
316
+ }),
317
+ };
318
+ }),
319
+ };
320
+ }
321
+
322
+ function facadeOps(app: App, entityName: string, surface?: string): CardOp[] {
323
+ let facade: AnyFacade;
324
+ try {
325
+ facade = app.container.resolve<AnyFacade>(facadeKeyOf(entityName, surface));
326
+ } catch {
327
+ return [];
328
+ }
329
+
330
+ // The façade is the list of names; the model is the resolved terms.
331
+ const effective = app.operationsFor(entityName, surface);
332
+ if (!effective) {
333
+ throw new Error(
334
+ `Facade '${facadeKeyOf(entityName, surface)}' exists without an effective operation table.`,
335
+ );
336
+ }
337
+
338
+ return Object.keys(facade).map((name) => {
339
+ const contract = effective.get(name);
340
+ if (!contract) {
341
+ throw new Error(
342
+ `Facade '${facadeKeyOf(entityName, surface)}' serves '${name}' without an effective contract.`,
343
+ );
344
+ }
345
+
346
+ return {
347
+ name,
348
+ ...(contract?.description && { description: contract.description }),
349
+ ...(contract?.input && { input: Card.fromSchema(contract.input, name).descriptor }),
350
+ ...(contract?.output && { output: Card.fromSchema(contract.output, name).descriptor }),
351
+ ...(contract?.cardinality && { cardinality: contract.cardinality }),
352
+ kind: contract.kind,
353
+ };
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Build the local runner — the reference realization of Transport.
359
+ *
360
+ * Resolves strictly from the app's own container: a call that lands here is
361
+ * judged here. A miss is a typed NOT_FOUND, never a forward to another remote.
362
+ */
363
+ export function createLocalRunner(app: App, surface?: string): Transport {
364
+ return createTransportEntry(app.local, surface);
365
+ }
366
+
367
+ /**
368
+ * Build the app runner — same judgment, but resolution follows the app's
369
+ * topology: local façades and remote doublures alike. This is the runner
370
+ * an app's own entry points (browser endpoint, bridges) stand on.
371
+ *
372
+ * `surface` is the audience this runner serves, and it belongs to the DOOR that
373
+ * builds the runner, never to the call: a caller cannot name its own audience
374
+ * any more than it can name its own identity (`state` is stamped server-side
375
+ * for the same reason). Reaching the admin door IS the proof. So `FrondCall`
376
+ * gains nothing and the wire format gains nothing — a remote frond's audience
377
+ * is simply which URL `remotes:` points at.
378
+ */
379
+ export function createAppRunner(app: App, surface?: string): Transport {
380
+ return createTransportEntry(app, surface);
381
+ }
@@ -0,0 +1,131 @@
1
+ import { Card, type Change, type SchemaDescriptor } from '@fougere/schema';
2
+ import type { IdentityCard } from './call.js';
3
+
4
+ /**
5
+ * What separates the copy a consumer holds from what the producer actually serves.
6
+ *
7
+ * It lives beside `IdentityCard` and not in `@fougere/testing` for the reason the card
8
+ * itself does: the subject is the card, and a second reader arrived — the dev panel, which
9
+ * must not drag a 426 KB faker in to compare two of them.
10
+ */
11
+ export interface CardDrift {
12
+ frond: string;
13
+ /** A door the consumer calls that the producer no longer serves. */
14
+ missingDoors: string[];
15
+ /** An operation the consumer calls that the door no longer has. */
16
+ missingOps: { door: string; ops: string[] }[];
17
+ /** A shape that moved under a door the consumer still calls. */
18
+ shapes: { door: string; changes: Change[] }[];
19
+ /** A fact the consumer subscribes to whose shape moved, or that is gone. */
20
+ facts: { fact: string; changes: Change[] | 'gone' }[];
21
+ }
22
+
23
+ /** Every door of a card, by name. */
24
+ function doorsOf(card: IdentityCard, frond: string): Map<string, { ops: Set<string>; schema?: SchemaDescriptor }> {
25
+ const found = new Map<string, { ops: Set<string>; schema?: SchemaDescriptor }>();
26
+ for (const one of card.fronds) {
27
+ if (one.name !== frond) continue;
28
+ for (const door of one.doors) {
29
+ found.set(door.name, { ops: new Set(door.ops.map((op) => op.name)), schema: door.schema });
30
+ }
31
+ }
32
+ return found;
33
+ }
34
+
35
+ function factsOf(card: IdentityCard, frond: string): Map<string, SchemaDescriptor | undefined> {
36
+ const found = new Map<string, SchemaDescriptor | undefined>();
37
+ for (const one of card.fronds) {
38
+ if (one.name !== frond) continue;
39
+ for (const fact of one.facts ?? []) found.set(fact.name, fact.schema as SchemaDescriptor | undefined);
40
+ }
41
+ return found;
42
+ }
43
+
44
+ /**
45
+ * What a consumer's synced copy no longer matches in what the producer serves.
46
+ *
47
+ * The gap TypeScript cannot see, and the only place the gradient genuinely lies: the code
48
+ * is identical in-process and split, but one side may have aged. `fougere sync` wrote the
49
+ * consumer's copy three weeks ago, the producer moved on, and it still compiles —
50
+ * production is where that is found today. This is what Pact sells; the material was
51
+ * already here, in `rpc.discover` and in `Card.diff`.
52
+ *
53
+ * Read in ONE direction on purpose: what the consumer holds, checked against what is
54
+ * served. A producer serving MORE than the consumer knows is not drift — it is a producer
55
+ * that moved forward without breaking anyone, which is the whole point of the order the
56
+ * repo already states (re-sync the readers, then deploy the sender).
57
+ */
58
+ export function driftOf(mine: IdentityCard, theirs: IdentityCard, frond: string): CardDrift {
59
+ const held = doorsOf(mine, frond);
60
+ const served = doorsOf(theirs, frond);
61
+ const drift: CardDrift = { frond, missingDoors: [], missingOps: [], shapes: [], facts: [] };
62
+
63
+ for (const [name, door] of held) {
64
+ const there = served.get(name);
65
+ if (!there) { drift.missingDoors.push(name); continue; }
66
+
67
+ const missing = [...door.ops].filter((op) => !there.ops.has(op));
68
+ if (missing.length > 0) drift.missingOps.push({ door: name, ops: missing.sort() });
69
+
70
+ if (door.schema && there.schema) {
71
+ // `Card.diff` never guesses a rename — a field gone plus a field appeared lands in
72
+ // `ambiguous`, and only a declaration settles it. Here nobody can declare one, so
73
+ // the pair is reported as it is and a human reads it.
74
+ const moved = Card.fromDescriptor(door.schema).diff(Card.fromDescriptor(there.schema));
75
+ if (moved.changes.length > 0) drift.shapes.push({ door: name, changes: moved.changes });
76
+ }
77
+ }
78
+
79
+ const heldFacts = factsOf(mine, frond);
80
+ const servedFacts = factsOf(theirs, frond);
81
+ for (const [name, shape] of heldFacts) {
82
+ if (!servedFacts.has(name)) { drift.facts.push({ fact: name, changes: 'gone' }); continue; }
83
+ const there = servedFacts.get(name);
84
+ if (!shape || !there) continue;
85
+ const moved = Card.fromDescriptor(shape).diff(Card.fromDescriptor(there));
86
+ if (moved.changes.length > 0) drift.facts.push({ fact: name, changes: moved.changes });
87
+ }
88
+
89
+ return drift;
90
+ }
91
+
92
+ /** Whether anything at all separates the two cards. */
93
+ export function agrees(drift: CardDrift): boolean {
94
+ return drift.missingDoors.length === 0
95
+ && drift.missingOps.length === 0
96
+ && drift.shapes.length === 0
97
+ && drift.facts.length === 0;
98
+ }
99
+
100
+ /**
101
+ * The drift, in the words a deploy needs.
102
+ *
103
+ * A fact says the order out loud, because the repo already states it as a rule and
104
+ * nothing enforced it: a fact is judged strictly, so a reader that has not been re-synced
105
+ * refuses what the sender now announces.
106
+ */
107
+ export function explain(drift: CardDrift): string[] {
108
+ const lines: string[] = [];
109
+ for (const door of drift.missingDoors) lines.push(`${drift.frond}.${door} — you call it, it is not served`);
110
+ for (const { door, ops } of drift.missingOps) lines.push(`${drift.frond}.${door} — gone: ${ops.join(', ')}`);
111
+ for (const { door, changes } of drift.shapes) {
112
+ for (const change of changes) lines.push(`${drift.frond}.${door} — ${describe(change)}`);
113
+ }
114
+ for (const { fact, changes } of drift.facts) {
115
+ if (changes === 'gone') { lines.push(`${fact} — you subscribe to it, it is no longer announced`); continue; }
116
+ for (const change of changes) lines.push(`${fact} — ${describe(change)} → re-sync and deploy the readers, THEN the sender`);
117
+ }
118
+ return lines;
119
+ }
120
+
121
+ function describe(change: Change): string {
122
+ switch (change.kind) {
123
+ case 'added': return `+ ${change.field}${change.required ? ' (required)' : ''}`;
124
+ case 'removed': return `- ${change.field}`;
125
+ case 'renamed': return `${change.from} → ${change.to}`;
126
+ case 'retyped': return `${change.field}: ${[...change.from].join('|')} → ${[...change.to].join('|')}`;
127
+ case 'reshaped': return `${change.field}: its bounds moved`;
128
+ case 'required': return `${change.field}: ${change.from ? 'no longer' : 'now'} required`;
129
+ case 'restated': return `${change.field}: its ${change.axis} moved`;
130
+ }
131
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The error vocabulary — what a refusal IS, independently of who hears it.
3
+ *
4
+ * It travels on the wire (`toJSON`/`fromJSON`) and through the browser-safe
5
+ * `@fougere/core/contract`, so nothing here may know about HTTP, a logger, or a
6
+ * middleware chain. Its HTTP reading is one file over, in `http-error.ts`; it used
7
+ * to live in `middleware.ts`, which named the one concept this is not.
8
+ */
9
+
10
+ import type { ValidationError } from '@fougere/schema';
11
+
12
+ // ── ErrorCode ──────────────────────────────────
13
+
14
+ /** Semantic error codes — transport-agnostic. Each bridge maps them to its own format. */
15
+ export enum ErrorCode {
16
+ // Input
17
+ VALIDATION_FAILED = 'VALIDATION_FAILED',
18
+ BAD_REQUEST = 'BAD_REQUEST',
19
+
20
+ // Auth
21
+ UNAUTHORIZED = 'UNAUTHORIZED',
22
+ FORBIDDEN = 'FORBIDDEN',
23
+
24
+ // Resources
25
+ NOT_FOUND = 'NOT_FOUND',
26
+ GONE = 'GONE',
27
+ CONFLICT = 'CONFLICT',
28
+ LOCKED = 'LOCKED',
29
+ METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
30
+
31
+ // Limits
32
+ PRECONDITION_FAILED = 'PRECONDITION_FAILED',
33
+ PAYLOAD_TOO_LARGE = 'PAYLOAD_TOO_LARGE',
34
+ UNPROCESSABLE_ENTITY = 'UNPROCESSABLE_ENTITY',
35
+ TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
36
+ REQUEST_TIMEOUT = 'REQUEST_TIMEOUT',
37
+
38
+ // Server
39
+ INTERNAL_ERROR = 'INTERNAL_ERROR',
40
+ NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
41
+ BAD_GATEWAY = 'BAD_GATEWAY',
42
+ SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
43
+ GATEWAY_TIMEOUT = 'GATEWAY_TIMEOUT',
44
+ }
45
+
46
+ // ── FougereError ────────────────────────────────
47
+
48
+ export interface FougereErrorOptions {
49
+ code: ErrorCode;
50
+ message: string;
51
+ entity?: string;
52
+ operation?: string;
53
+ details?: unknown;
54
+ cause?: unknown;
55
+ }
56
+
57
+ export class FougereError extends Error {
58
+ readonly code: ErrorCode;
59
+ readonly entity?: string;
60
+ readonly operation?: string;
61
+ readonly details?: unknown;
62
+
63
+ constructor(options: FougereErrorOptions) {
64
+ super(options.message, { cause: options.cause });
65
+ this.name = 'FougereError';
66
+ this.code = options.code;
67
+ this.entity = options.entity;
68
+ this.operation = options.operation;
69
+ this.details = options.details;
70
+ }
71
+
72
+ toJSON() {
73
+ return {
74
+ code: this.code,
75
+ message: this.message,
76
+ ...(this.entity && { entity: this.entity }),
77
+ ...(this.operation && { operation: this.operation }),
78
+ ...(this.details !== undefined && { details: this.details }),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Dual of toJSON — rebuild a typed error from its wire form.
84
+ *
85
+ * Wire input is untrusted: an unknown code degrades to INTERNAL_ERROR
86
+ * (original code kept in details) instead of forging a fake semantic code.
87
+ */
88
+ static fromJSON(json: unknown): FougereError {
89
+ const raw = (typeof json === 'object' && json !== null ? json : {}) as Record<string, unknown>;
90
+ const known = Object.values(ErrorCode).includes(raw.code as ErrorCode);
91
+ return new FougereError({
92
+ code: known ? (raw.code as ErrorCode) : ErrorCode.INTERNAL_ERROR,
93
+ message: typeof raw.message === 'string' ? raw.message : 'Unknown error',
94
+ entity: typeof raw.entity === 'string' ? raw.entity : undefined,
95
+ operation: typeof raw.operation === 'string' ? raw.operation : undefined,
96
+ details: known ? raw.details : { originalCode: raw.code, details: raw.details },
97
+ });
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The refusals behind a VALIDATION_FAILED, or nothing — the ONE place that reads
103
+ * `details` under that code.
104
+ *
105
+ * `details` is `unknown` because it is genuinely polymorphic: `StorageGuard.judge` puts a
106
+ * `string[]` there under INTERNAL_ERROR, `fromJSON` puts `{ originalCode, details }`
107
+ * there for a code it does not know. So the shape is not the field's, it is a reading
108
+ * of the PAIR (code, details) — which is why this is a reader and not a narrower type.
109
+ *
110
+ * It judges rather than casts: five call sites asserted `{ path, message }[]` by hand,
111
+ * and a malformed VALIDATION_FAILED would have been accepted as structured refusals.
112
+ */
113
+ export function validationErrorsOf(error: unknown): ValidationError[] | undefined {
114
+ if (!(error instanceof FougereError) || error.code !== ErrorCode.VALIDATION_FAILED) return undefined;
115
+ const { details } = error;
116
+ if (!Array.isArray(details)) return undefined;
117
+ const refusals = details.filter(
118
+ (entry): entry is ValidationError =>
119
+ typeof entry === 'object' && entry !== null
120
+ && typeof (entry as ValidationError).path === 'string'
121
+ && typeof (entry as ValidationError).message === 'string',
122
+ );
123
+ return refusals.length === details.length ? refusals : undefined;
124
+ }