@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,45 @@
1
+ import { FougereError, ErrorCode } from '../wire/errors.js';
2
+
3
+ /** Controls dispatch admission and signals when every accepted call has settled. */
4
+ export class InFlight {
5
+ private running = 0;
6
+ private accepting = true;
7
+ private idle: (() => void)[] = [];
8
+
9
+ get count(): number {
10
+ return this.running;
11
+ }
12
+
13
+ enter(entity: string, operation: string): () => void {
14
+ if (!this.accepting) {
15
+ throw new FougereError({
16
+ code: ErrorCode.SERVICE_UNAVAILABLE,
17
+ message: 'This app is being released and takes no new call — reach the current one through its handle.',
18
+ entity,
19
+ operation,
20
+ });
21
+ }
22
+
23
+ this.running++;
24
+ let released = false;
25
+ return () => {
26
+ if (released) return;
27
+ released = true;
28
+ this.running--;
29
+ if (this.running !== 0) return;
30
+
31
+ const waiting = this.idle;
32
+ this.idle = [];
33
+ for (const wake of waiting) wake();
34
+ };
35
+ }
36
+
37
+ close(): void {
38
+ this.accepting = false;
39
+ }
40
+
41
+ whenIdle(): Promise<void> {
42
+ if (this.running === 0) return Promise.resolve();
43
+ return new Promise((resolve) => this.idle.push(resolve));
44
+ }
45
+ }
@@ -0,0 +1,28 @@
1
+ import { RowJudge, type SchemaView } from '@fougere/schema';
2
+ import type { InvocationContext } from '../contract/Invocation.js';
3
+ import { ErrorCode, FougereError } from '../wire/errors.js';
4
+
5
+ /** Validates and decodes the input body declared by one operation contract. */
6
+ export class InputValidator {
7
+ validate(
8
+ schema: SchemaView | undefined,
9
+ invocation: InvocationContext,
10
+ entity: string,
11
+ operation: string,
12
+ ): InvocationContext {
13
+ if (!schema || invocation.body === undefined || invocation.body === null) return invocation;
14
+
15
+ const result = RowJudge.of(schema.getFields(), { patch: schema.getOpts().patch })
16
+ .check(invocation.body);
17
+ if (!result.success) {
18
+ throw new FougereError({
19
+ code: ErrorCode.VALIDATION_FAILED,
20
+ message: result.errors.map((error) => `${error.path}: ${error.message}`).join(', '),
21
+ details: result.errors,
22
+ entity,
23
+ operation,
24
+ });
25
+ }
26
+ return { ...invocation, body: result.data };
27
+ }
28
+ }
@@ -0,0 +1,34 @@
1
+ import type { Call } from '../contract/Call.js';
2
+ import { ErrorCode, FougereError } from '../wire/errors.js';
3
+ import type { Route } from './Route.js';
4
+ import type { RoutePolicy } from './RoutePolicy.js';
5
+ import { RouteNotFoundError } from './RouteNotFoundError.js';
6
+
7
+ /** Incoming calls may execute here but never forward to another host. */
8
+ export class LocalRoutePolicy implements RoutePolicy {
9
+ constructor(private readonly hostedNames: (surface?: string) => string[]) {}
10
+
11
+ accepts(route: Route): boolean {
12
+ return route.kind !== 'remote';
13
+ }
14
+
15
+ notFound(call: Call, routes: readonly Route[]): Error | undefined {
16
+ if (call.address.entity === 'rpc') return undefined;
17
+
18
+ const unknownRoute = new RouteNotFoundError(call, routes, this);
19
+ if (unknownRoute.servedOperations.length > 0) return unknownRoute;
20
+
21
+ const hosted = this.hostedNames(call.address.surface);
22
+ const entity = call.address.entity;
23
+ const surface = call.address.surface;
24
+ return new FougereError({
25
+ code: ErrorCode.NOT_FOUND,
26
+ message: (surface
27
+ ? `Entity '${entity}' is not served on surface '${surface}'`
28
+ : `Entity '${entity}' is not hosted here`)
29
+ + (hosted.length ? `. Hosted here: ${hosted.join(', ')}.` : '. This app hosts no entity.'),
30
+ entity,
31
+ operation: call.address.operation,
32
+ });
33
+ }
34
+ }
@@ -0,0 +1,60 @@
1
+ import type { InvocationContext } from '../contract/Invocation.js';
2
+ import { canonicalInvocation } from '../contract/Invocation.js';
3
+ import type { OperationContract } from '../wire/operation.js';
4
+ import {
5
+ runMiddlewares,
6
+ type AppMiddleware,
7
+ type OperationContext,
8
+ } from '../wire/middleware.js';
9
+ import type { ArgumentResolver } from './ArgumentResolver.js';
10
+ import type { InputValidator } from './InputValidator.js';
11
+ import type { OutputProjector } from './OutputProjector.js';
12
+
13
+ export interface OperationExecution {
14
+ entity: string;
15
+ frond: string;
16
+ operation: string;
17
+ contract: OperationContract | undefined;
18
+ middlewares: () => AppMiddleware[];
19
+ validator: InputValidator;
20
+ arguments: ArgumentResolver;
21
+ invoke: (args: unknown[]) => unknown | Promise<unknown>;
22
+ projector: OutputProjector;
23
+ present?: (result: unknown, invocation: InvocationContext) => Promise<unknown>;
24
+ }
25
+
26
+ /** Executes one operation through the same ordered boundary steps on every route. */
27
+ export class OperationExecutor {
28
+ constructor(private readonly execution: OperationExecution) {}
29
+
30
+ async execute(input?: Partial<InvocationContext>): Promise<unknown> {
31
+ const invocation = canonicalInvocation(input);
32
+ const context: OperationContext = {
33
+ entity: this.execution.entity,
34
+ frond: this.execution.frond,
35
+ operation: this.execution.operation,
36
+ args: [],
37
+ state: invocation.state,
38
+ invocation,
39
+ };
40
+ return runMiddlewares(this.execution.middlewares(), context, async () => {
41
+ const effective = this.execution.validator.validate(
42
+ this.execution.contract?.input,
43
+ invocation,
44
+ context.entity,
45
+ context.operation,
46
+ );
47
+ context.invocation = effective;
48
+
49
+ const args = this.execution.contract?.binding
50
+ ? await this.execution.arguments.resolve(this.execution.contract.binding, effective)
51
+ : [];
52
+ const projected = this.execution.projector.project(
53
+ await this.execution.invoke(args),
54
+ );
55
+ return this.execution.present
56
+ ? this.execution.present(projected, effective)
57
+ : projected;
58
+ });
59
+ }
60
+ }
@@ -0,0 +1,18 @@
1
+ import type { Call } from '../contract/Call.js';
2
+ import type { RouteAddress } from '../contract/RouteAddress.js';
3
+ import type { Route } from './Route.js';
4
+
5
+ export type RouteExecution = (call: Call) => unknown | Promise<unknown>;
6
+
7
+ /** One executable route, distinguished by its routing kind. */
8
+ export class OperationRoute implements Route {
9
+ constructor(
10
+ readonly kind: Route['kind'],
11
+ readonly address: RouteAddress,
12
+ private readonly execution: RouteExecution,
13
+ ) {}
14
+
15
+ async execute(call: Call): Promise<unknown> {
16
+ return (await this.execution(call)) ?? null;
17
+ }
18
+ }
@@ -0,0 +1,32 @@
1
+ import { Visibility } from '@fougere/schema';
2
+ import { preserveArrayProperties } from './ArrayResult.js';
3
+ import type { OutputView } from './OutputView.js';
4
+
5
+ /**
6
+ * Projects one operation result onto the fields its audience may read.
7
+ *
8
+ * One of the two exits a judge watches; `StorageGuard` is the other.
9
+ */
10
+ export class OutputProjector {
11
+ constructor(private readonly view: OutputView) {}
12
+
13
+ project(result: unknown): unknown {
14
+ if (result === null || result === undefined) return result;
15
+
16
+ if (Array.isArray(result)) {
17
+ return preserveArrayProperties(result, result.map((item) => this.project(item)));
18
+ }
19
+
20
+ if (typeof result !== 'object') return result;
21
+
22
+ const record = result as Record<string, unknown>;
23
+ const scoped = this.view.closed
24
+ ? Object.fromEntries(
25
+ Object.keys(this.view.fields)
26
+ .filter((key) => key in record)
27
+ .map((key) => [key, record[key]]),
28
+ )
29
+ : record;
30
+ return Visibility.of(this.view.fields).encode(scoped);
31
+ }
32
+ }
@@ -0,0 +1,9 @@
1
+ import type { Fields } from '@fougere/schema';
2
+
3
+ /** Immutable projection boundary of one operation result. */
4
+ export class OutputView {
5
+ constructor(
6
+ readonly fields: Fields,
7
+ readonly closed = false,
8
+ ) {}
9
+ }
@@ -0,0 +1,25 @@
1
+ import { computeBindingPlan } from '../boot/binding.js';
2
+ import type { InvocationContext } from '../contract/Invocation.js';
3
+ import type { PresenterEntry } from '../scan/frond.js';
4
+ import type { ArgumentResolver } from './ArgumentResolver.js';
5
+ import type { PresenterArgs } from './PresenterExecutor.js';
6
+
7
+ /** Resolves the invocation-dependent arguments of every computed presenter field. */
8
+ export class PresenterArgumentResolver {
9
+ constructor(
10
+ private readonly argumentsResolver: ArgumentResolver,
11
+ private readonly collectorTypes: Set<string>,
12
+ ) {}
13
+
14
+ async resolve(meta: PresenterEntry, invocation: InvocationContext): Promise<PresenterArgs> {
15
+ const argumentsByField: PresenterArgs = {};
16
+ for (const field of meta.fieldMeta) {
17
+ if (!field.params?.length) continue;
18
+ argumentsByField[field.name] = await this.argumentsResolver.resolve(
19
+ computeBindingPlan(field.params, this.collectorTypes),
20
+ invocation,
21
+ );
22
+ }
23
+ return argumentsByField;
24
+ }
25
+ }
@@ -0,0 +1,55 @@
1
+ import { ErrorCode, FougereError } from '../wire/errors.js';
2
+ import { preserveArrayProperties } from './ArrayResult.js';
3
+
4
+ export type PresenterArgs = Record<string, unknown[]>;
5
+
6
+ /** Adds a presenter's computed fields after output projection. */
7
+ export class PresenterExecutor {
8
+ constructor(
9
+ private readonly presenter: Record<string, unknown> | undefined,
10
+ private readonly fieldNames: readonly string[] | undefined,
11
+ private readonly entity = 'unknown',
12
+ private readonly operation = 'unknown',
13
+ ) {}
14
+
15
+ async present(result: unknown, args: PresenterArgs = {}): Promise<unknown> {
16
+ if (!this.presenter || !this.fieldNames?.length || result === null || typeof result !== 'object') {
17
+ return result;
18
+ }
19
+
20
+ const rows = Array.isArray(result) ? result : [result];
21
+ const values = new Map<string, unknown[]>();
22
+
23
+ for (const name of this.fieldNames) {
24
+ const field = this.presenter[name];
25
+ if (typeof field !== 'function') continue;
26
+ try {
27
+ const answered = await field.call(this.presenter, rows, ...(args[name] ?? []));
28
+ if (!Array.isArray(answered) || answered.length !== rows.length) {
29
+ const actual = Array.isArray(answered) ? answered.length : typeof answered;
30
+ throw new Error(`expected ${rows.length} value(s) for ${rows.length} row(s), got ${actual}`);
31
+ }
32
+ values.set(name, answered);
33
+ } catch (cause) {
34
+ throw new FougereError({
35
+ code: ErrorCode.INTERNAL_ERROR,
36
+ message: `Computed field '${name}' failed: ${(cause as Error)?.message ?? cause}`,
37
+ entity: this.entity,
38
+ operation: this.operation,
39
+ cause,
40
+ });
41
+ }
42
+ }
43
+
44
+ const enriched = rows.map((item, index) => {
45
+ if (item === null || typeof item !== 'object') return item;
46
+ const output = { ...(item as Record<string, unknown>) };
47
+ for (const [name, answered] of values) output[name] = answered[index];
48
+ return output;
49
+ });
50
+
51
+ return Array.isArray(result)
52
+ ? preserveArrayProperties(result, enriched)
53
+ : enriched[0];
54
+ }
55
+ }
@@ -0,0 +1,21 @@
1
+ import type { RouteAddress } from '../contract/RouteAddress.js';
2
+ import { OperationRoute } from './OperationRoute.js';
3
+ import type { Route } from './Route.js';
4
+ import type { RouteResolver } from './RouteResolver.js';
5
+
6
+ type RemoteFacade = Record<string, Function>;
7
+ export type RemoteFacadeResolver = (entity: string) => RemoteFacade | undefined;
8
+
9
+ /** Builds a remote route on the first call, after the remote façade can be resolved. */
10
+ export class RemoteRouteResolver implements RouteResolver {
11
+ constructor(private readonly facades: RemoteFacadeResolver) {}
12
+
13
+ resolve(address: RouteAddress): Route | undefined {
14
+ if (address.surface !== undefined) return undefined;
15
+
16
+ const facade = this.facades(address.entity);
17
+ const operation = facade?.[address.operation];
18
+ if (typeof operation !== 'function') return undefined;
19
+ return new OperationRoute('remote', address, (call) => operation(call.invocation));
20
+ }
21
+ }
@@ -0,0 +1,11 @@
1
+ import type { Call } from '../contract/Call.js';
2
+ import type { RouteAddress } from '../contract/RouteAddress.js';
3
+
4
+ export type RouteKind = 'local' | 'remote' | 'system';
5
+
6
+ /** Executable destination known by the dispatcher. */
7
+ export interface Route {
8
+ readonly kind: RouteKind;
9
+ readonly address: RouteAddress;
10
+ execute(call: Call): Promise<unknown>;
11
+ }
@@ -0,0 +1,29 @@
1
+ import type { Call } from '../contract/Call.js';
2
+ import { ErrorCode, FougereError } from '../wire/errors.js';
3
+ import type { Route } from './Route.js';
4
+ import type { RoutePolicy } from './RoutePolicy.js';
5
+
6
+ /** Describes an unresolved route from the destinations visible to one dispatcher. */
7
+ export class RouteNotFoundError extends FougereError {
8
+ readonly servedOperations: readonly string[];
9
+
10
+ constructor(call: Call, routes: readonly Route[], policy?: RoutePolicy) {
11
+ const served = routes
12
+ .filter((route) => !policy || policy.accepts(route))
13
+ .filter((route) => route.address.entity === call.address.entity)
14
+ .filter((route) => route.kind === 'system'
15
+ || route.address.surface === call.address.surface)
16
+ .map((route) => route.address.operation);
17
+ const operation = call.address.operation;
18
+ const entity = call.address.entity;
19
+ const message = entity === 'rpc'
20
+ ? `Unknown rpc operation '${operation}'. `
21
+ + (served.length ? `It serves ${served.join(', ')}.` : 'It serves nothing.')
22
+ : served.length
23
+ ? `Unknown operation '${operation}' on '${entity}'. It serves ${served.join(', ')}.`
24
+ : `No route serves '${call.address}'`;
25
+
26
+ super({ code: ErrorCode.NOT_FOUND, message, entity, operation });
27
+ this.servedOperations = served;
28
+ }
29
+ }
@@ -0,0 +1,8 @@
1
+ import type { Call } from '../contract/Call.js';
2
+ import type { Route } from './Route.js';
3
+
4
+ /** Restricts which registered routes one dispatch capability may enter. */
5
+ export interface RoutePolicy {
6
+ accepts(route: Route): boolean;
7
+ notFound?(call: Call, routes: readonly Route[]): Error | undefined;
8
+ }
@@ -0,0 +1,73 @@
1
+ import { RouteAddress } from '../contract/RouteAddress.js';
2
+ import type { Route } from './Route.js';
3
+ import type { RouteResolver } from './RouteResolver.js';
4
+
5
+ /** Exact registry of every operation the application can dispatch. */
6
+ export class RouteRegistry {
7
+ private readonly byAddress = new Map<string, Route>();
8
+ private readonly resolvers: RouteResolver[] = [];
9
+ private readonly pending = new Map<string, Promise<Route | undefined>>();
10
+
11
+ get size(): number {
12
+ return this.byAddress.size;
13
+ }
14
+
15
+ register(route: Route): void {
16
+ const key = route.address.key();
17
+ const existing = this.byAddress.get(key);
18
+ if (existing) {
19
+ throw new Error(
20
+ `Route collision at '${route.address}': ${existing.kind} and ${route.kind}`,
21
+ );
22
+ }
23
+ this.byAddress.set(key, route);
24
+ }
25
+
26
+ addResolver(resolver: RouteResolver): void {
27
+ this.resolvers.push(resolver);
28
+ }
29
+
30
+ find(address: RouteAddress): Route | undefined {
31
+ const exact = this.byAddress.get(address.key());
32
+ if (exact || address.surface === undefined) return exact;
33
+
34
+ const shared = this.byAddress.get(new RouteAddress({
35
+ entity: address.entity,
36
+ operation: address.operation,
37
+ }).key());
38
+ return shared?.kind === 'system' ? shared : undefined;
39
+ }
40
+
41
+ async resolve(address: RouteAddress): Promise<Route | undefined> {
42
+ const known = this.find(address);
43
+ if (known) return known;
44
+
45
+ const key = address.key();
46
+ const running = this.pending.get(key);
47
+ if (running) return running;
48
+
49
+ const resolution = this.resolveUnknown(address).finally(() => this.pending.delete(key));
50
+ this.pending.set(key, resolution);
51
+ return resolution;
52
+ }
53
+
54
+ private async resolveUnknown(address: RouteAddress): Promise<Route | undefined> {
55
+ for (const resolver of this.resolvers) {
56
+ const route = await resolver.resolve(address);
57
+ if (!route) continue;
58
+ this.register(route);
59
+ return route;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ routes(): Route[] {
65
+ return [...this.byAddress.values()];
66
+ }
67
+
68
+ operationNames(entity: string, surface?: string): string[] {
69
+ return this.routes()
70
+ .filter((route) => route.address.entity === entity && route.address.surface === surface)
71
+ .map((route) => route.address.operation);
72
+ }
73
+ }
@@ -0,0 +1,7 @@
1
+ import type { RouteAddress } from '../contract/RouteAddress.js';
2
+ import type { Route } from './Route.js';
3
+
4
+ /** Resolves routes that cannot be known when the registry is built. */
5
+ export interface RouteResolver {
6
+ resolve(address: RouteAddress): Route | undefined | Promise<Route | undefined>;
7
+ }
@@ -0,0 +1,71 @@
1
+ import { ValueJudge, type Fields } from '@fougere/schema';
2
+ import { assertListOptions } from '../orm.js';
3
+ import { ErrorCode, FougereError } from '../wire/errors.js';
4
+
5
+ interface Writer {
6
+ create(...args: [Record<string, unknown>, ...unknown[]]): Promise<unknown>;
7
+ update(...args: [unknown, Record<string, unknown>, ...unknown[]]): Promise<unknown>;
8
+ }
9
+
10
+ /**
11
+ * Judges storage writes and list options without narrowing the ORM interface.
12
+ *
13
+ * Storage is a way out like the client surface, so what goes to it is judged too — the
14
+ * same rule `OutputProjector` applies on the other exit.
15
+ */
16
+ export class StorageGuard {
17
+ constructor(
18
+ private readonly fields: Fields,
19
+ private readonly entity: string,
20
+ ) {}
21
+
22
+ guard<T extends object>(orm: T): T {
23
+ const writer = orm as unknown as Writer;
24
+ if (typeof writer.create !== 'function' || typeof writer.update !== 'function') return orm;
25
+
26
+ const validation = this;
27
+ const guarded = Object.create(orm) as T & Writer;
28
+
29
+ guarded.create = async function (...args) {
30
+ validation.judge(args[0], 'create');
31
+ return writer.create.apply(this, args);
32
+ };
33
+
34
+ guarded.update = async function (...args) {
35
+ validation.judge(args[1], 'update');
36
+ return writer.update.apply(this, args);
37
+ };
38
+
39
+ const reader = orm as unknown as { list?: (...args: unknown[]) => unknown };
40
+ if (typeof reader.list === 'function') {
41
+ (guarded as unknown as typeof reader).list = async function (...args: unknown[]) {
42
+ assertListOptions(args[0] as object | undefined, validation.entity);
43
+ return (reader.list as Function).apply(this, args);
44
+ };
45
+ }
46
+
47
+ return guarded;
48
+ }
49
+
50
+ private judge(value: unknown, operation: string): void {
51
+ if (typeof value !== 'object' || value === null) return;
52
+
53
+ const errors: string[] = [];
54
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
55
+ const field = this.fields[key];
56
+ if (!field || item === undefined) continue;
57
+ const checked = ValueJudge.of(field).check(item);
58
+ if ('error' in checked) errors.push(`${key}: ${checked.error}`);
59
+ }
60
+
61
+ if (errors.length > 0) {
62
+ throw new FougereError({
63
+ code: ErrorCode.INTERNAL_ERROR,
64
+ message: `Refused on the way out — ${errors.join(', ')}`,
65
+ entity: this.entity,
66
+ operation,
67
+ details: errors,
68
+ });
69
+ }
70
+ }
71
+ }