@fougere/core 0.4.0-alpha.0 → 0.5.0-alpha.1

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 (124) hide show
  1. package/dist/boot/Emissions.js +1 -1
  2. package/dist/boot/Emissions.js.map +1 -1
  3. package/dist/boot/binding.d.ts +1 -1
  4. package/dist/boot/binding.d.ts.map +1 -1
  5. package/dist/boot/bootstrap.d.ts.map +1 -1
  6. package/dist/boot/bootstrap.js +12 -4
  7. package/dist/boot/bootstrap.js.map +1 -1
  8. package/dist/boot/hosted.d.ts +29 -0
  9. package/dist/boot/hosted.d.ts.map +1 -0
  10. package/dist/boot/hosted.js +38 -0
  11. package/dist/boot/hosted.js.map +1 -0
  12. package/dist/boot/ownership.d.ts +0 -3
  13. package/dist/boot/ownership.d.ts.map +1 -1
  14. package/dist/boot/ownership.js.map +1 -1
  15. package/dist/boot/remote.d.ts +1 -1
  16. package/dist/boot/remote.d.ts.map +1 -1
  17. package/dist/boot/remote.js +1 -1
  18. package/dist/boot/remote.js.map +1 -1
  19. package/dist/boot/statement-drift.d.ts +24 -0
  20. package/dist/boot/statement-drift.d.ts.map +1 -0
  21. package/dist/boot/statement-drift.js +38 -0
  22. package/dist/boot/statement-drift.js.map +1 -0
  23. package/dist/boot/types.d.ts +15 -2
  24. package/dist/boot/types.d.ts.map +1 -1
  25. package/dist/contract/CallLog.d.ts +57 -0
  26. package/dist/contract/CallLog.d.ts.map +1 -0
  27. package/dist/contract/CallLog.js +2 -0
  28. package/dist/contract/CallLog.js.map +1 -0
  29. package/dist/contract.d.ts +8 -3
  30. package/dist/contract.d.ts.map +1 -1
  31. package/dist/contract.js +9 -2
  32. package/dist/contract.js.map +1 -1
  33. package/dist/dispatch/DispatchLifecycle.d.ts +9 -1
  34. package/dist/dispatch/DispatchLifecycle.d.ts.map +1 -1
  35. package/dist/dispatch/DispatchLifecycle.js +17 -2
  36. package/dist/dispatch/DispatchLifecycle.js.map +1 -1
  37. package/dist/dispatch/InFlight.d.ts +0 -1
  38. package/dist/dispatch/InFlight.d.ts.map +1 -1
  39. package/dist/dispatch/InFlight.js +0 -3
  40. package/dist/dispatch/InFlight.js.map +1 -1
  41. package/dist/effective-operation.d.ts.map +1 -1
  42. package/dist/effective-operation.js +4 -0
  43. package/dist/effective-operation.js.map +1 -1
  44. package/dist/index.d.ts +8 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +12 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/node.d.ts +4 -2
  49. package/dist/node.d.ts.map +1 -1
  50. package/dist/node.js +4 -2
  51. package/dist/node.js.map +1 -1
  52. package/dist/scan/bundling.d.ts +14 -0
  53. package/dist/scan/bundling.d.ts.map +1 -0
  54. package/dist/scan/bundling.js +26 -0
  55. package/dist/scan/bundling.js.map +1 -0
  56. package/dist/scan/conventions.d.ts +8 -12
  57. package/dist/scan/conventions.d.ts.map +1 -1
  58. package/dist/scan/conventions.js +0 -13
  59. package/dist/scan/conventions.js.map +1 -1
  60. package/dist/scan/declare.d.ts +71 -0
  61. package/dist/scan/declare.d.ts.map +1 -0
  62. package/dist/scan/declare.js +125 -0
  63. package/dist/scan/declare.js.map +1 -0
  64. package/dist/scan/handler-parser.js +0 -1
  65. package/dist/scan/handler-parser.js.map +1 -1
  66. package/dist/scan/scanner.d.ts +17 -0
  67. package/dist/scan/scanner.d.ts.map +1 -1
  68. package/dist/scan/scanner.js +17 -1
  69. package/dist/scan/scanner.js.map +1 -1
  70. package/dist/scan/statement.d.ts +4 -0
  71. package/dist/scan/statement.d.ts.map +1 -0
  72. package/dist/scan/statement.js +112 -0
  73. package/dist/scan/statement.js.map +1 -0
  74. package/dist/wire/call.d.ts +12 -1
  75. package/dist/wire/call.d.ts.map +1 -1
  76. package/dist/wire/call.js +11 -0
  77. package/dist/wire/call.js.map +1 -1
  78. package/dist/wire/drift.d.ts +55 -0
  79. package/dist/wire/drift.d.ts.map +1 -0
  80. package/dist/wire/drift.js +121 -0
  81. package/dist/wire/drift.js.map +1 -0
  82. package/dist/wire/middleware.d.ts +1 -1
  83. package/dist/wire/middleware.d.ts.map +1 -1
  84. package/package.json +3 -3
  85. package/src/boot/Emissions.ts +1 -1
  86. package/src/boot/HandlerFacade.ts +1 -1
  87. package/src/boot/binding.ts +1 -1
  88. package/src/boot/bootstrap.ts +12 -4
  89. package/src/boot/hosted.ts +77 -0
  90. package/src/boot/ownership.ts +1 -4
  91. package/src/boot/remote.ts +1 -1
  92. package/src/boot/statement-drift.ts +60 -0
  93. package/src/boot/types.ts +15 -2
  94. package/src/contract/CallLog.ts +55 -0
  95. package/src/contract.ts +12 -3
  96. package/src/dispatch/DispatchLifecycle.ts +22 -2
  97. package/src/dispatch/InFlight.ts +0 -4
  98. package/src/effective-operation.ts +4 -0
  99. package/src/index.ts +16 -2
  100. package/src/node.ts +4 -2
  101. package/src/scan/bundling.ts +25 -0
  102. package/src/scan/conventions.ts +0 -18
  103. package/src/scan/declare.ts +188 -0
  104. package/src/scan/handler-parser.ts +0 -1
  105. package/src/scan/scanner.ts +22 -1
  106. package/src/scan/statement.ts +126 -0
  107. package/src/wire/call.ts +13 -1
  108. package/src/wire/drift.ts +131 -0
  109. package/src/wire/middleware.ts +1 -1
  110. package/dist/boot/Lifecycle.d.ts +0 -4
  111. package/dist/boot/Lifecycle.d.ts.map +0 -1
  112. package/dist/boot/Lifecycle.js +0 -3
  113. package/dist/boot/Lifecycle.js.map +0 -1
  114. package/dist/scan/scan-cache.d.ts +0 -16
  115. package/dist/scan/scan-cache.d.ts.map +0 -1
  116. package/dist/scan/scan-cache.js +0 -99
  117. package/dist/scan/scan-cache.js.map +0 -1
  118. package/dist/wire/invocation.d.ts +0 -4
  119. package/dist/wire/invocation.d.ts.map +0 -1
  120. package/dist/wire/invocation.js +0 -3
  121. package/dist/wire/invocation.js.map +0 -1
  122. package/src/boot/Lifecycle.ts +0 -3
  123. package/src/scan/scan-cache.ts +0 -115
  124. package/src/wire/invocation.ts +0 -10
@@ -0,0 +1,55 @@
1
+ import type { DispatchEvent } from '../dispatch/DispatchEvent.js';
2
+
3
+ /**
4
+ * What crosses the door: the address, the route it took, and the verdict.
5
+ *
6
+ * It lives here rather than beside its producer for the reason `TopologyReport` does: it
7
+ * crosses a process boundary, so a reader that never installed `@fougere/calls` — the CLI
8
+ * — needs its shape, and putting it there is what produces a hand-copied duplicate.
9
+ *
10
+ * Never the body. Same rule the topology report states — a remote destination is named,
11
+ * not disclosed — and it holds here for the same reason: this answer leaves the process.
12
+ */
13
+ export interface CallRecord {
14
+ /** Monotonic, and the whole cursor protocol: a reader asks for what is above its own. */
15
+ seq: number;
16
+ frond?: string;
17
+ entity: string;
18
+ operation: string;
19
+ surface?: string;
20
+ /** Known at `resolved`, so absent on a call that never found a route. */
21
+ route?: NonNullable<DispatchEvent['routeKind']>;
22
+ /**
23
+ * The traceparent the invocation carried, when one did.
24
+ *
25
+ * One panel reads one process, so this is what lets two of them be sewn: the process
26
+ * that sent and the process that executed share it, and the gap between their two
27
+ * durations IS the cost of the wire rather than a deduction.
28
+ */
29
+ trace?: string;
30
+ /**
31
+ * The peer that established this call, when one did.
32
+ *
33
+ * A hosted frond serves several consumers, so its own ring mixes them: this is what
34
+ * separates them. Absent on a call nobody signed — a loopback dev boot has no identity.
35
+ */
36
+ caller?: string;
37
+ startedAt: number;
38
+ /** Known at `settled`. */
39
+ ms?: number;
40
+ verdict: 'running' | 'ok' | 'failed';
41
+ refusal?: { code?: string; message: string };
42
+ }
43
+
44
+ /**
45
+ * One page of the ring.
46
+ *
47
+ * `dropped` is what the ring could not keep — an absence is named rather than left to
48
+ * look like a quiet period.
49
+ */
50
+ export interface CallPage {
51
+ calls: CallRecord[];
52
+ cursor: number;
53
+ inFlight: number;
54
+ dropped: number;
55
+ }
package/src/contract.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * is allowed in — measured, it imports no node builtin either.
10
10
  */
11
11
  import { lowerFirst } from '@fougere/schema';
12
- import { canonicalInvocation, type InvocationContext } from './wire/invocation.js';
12
+ import { canonicalInvocation, type InvocationContext } from './contract/Invocation.js';
13
13
  import type { FrondCall } from './wire/call.js';
14
14
 
15
15
  export { FougereError, ErrorCode, validationErrorsOf } from './wire/errors.js';
@@ -17,14 +17,23 @@ export { FougereError, ErrorCode, validationErrorsOf } from './wire/errors.js';
17
17
  // core's boot — reaching it through the main entry dragged the scanner into a bundle.
18
18
  export { toPublicError } from './wire/http-error.js';
19
19
  export type { FougereErrorOptions } from './wire/errors.js';
20
- export { canonicalInvocation, EMPTY_INVOCATION } from './wire/invocation.js';
21
- export type { InvocationContext } from './wire/invocation.js';
20
+ export { canonicalInvocation, EMPTY_INVOCATION } from './contract/Invocation.js';
21
+ export type { InvocationContext } from './contract/Invocation.js';
22
22
  export { Invocation } from './contract/Invocation.js';
23
23
  export type { InvocationInput } from './contract/Invocation.js';
24
24
  export { Call } from './contract/Call.js';
25
25
  export { RouteAddress } from './contract/RouteAddress.js';
26
26
  export type { RouteAddressInput } from './contract/RouteAddress.js';
27
27
  export type { FrondCall, Transport, SignedCall } from './wire/call.js';
28
+ export { MAX_BODY_BYTES } from './wire/call.js';
29
+ // The reserved entity, VALUE and not type: a consumer that wants to leave it alone — a
30
+ // call log ignoring its own reader — has to be able to name it.
31
+ export { RPC_ENTITY } from './wire/call.js';
32
+ export type { CallPage, CallRecord } from './contract/CallLog.js';
33
+ // The comparison of two cards, which a consumer runs about a producer — browser-safe on
34
+ // purpose: a panel showing the drift holds only the two cards, never the app.
35
+ export { driftOf, agrees, explain } from './wire/drift.js';
36
+ export type { CardDrift } from './wire/drift.js';
28
37
 
29
38
  /**
30
39
  * What `rpc.discover` answers. It belongs here and not to the runtime: the
@@ -2,10 +2,30 @@ import type { DispatchEvent, DispatchObserver } from './DispatchEvent.js';
2
2
 
3
3
  /** Publishes dispatch transitions without participating in dispatch decisions. */
4
4
  export class DispatchLifecycle {
5
+ private readonly observers: DispatchObserver[];
6
+
5
7
  constructor(
6
- private readonly observers: readonly DispatchObserver[] = [],
8
+ observers: readonly DispatchObserver[] = [],
7
9
  private readonly diagnose: (error: unknown, event: DispatchEvent) => void = () => {},
8
- ) {}
10
+ ) {
11
+ this.observers = [...observers];
12
+ }
13
+
14
+ /**
15
+ * Subscribe after the dispatcher was built, and get the unsubscription back.
16
+ *
17
+ * The list used to be settled at construction, so only the caller of `createApp` could
18
+ * observe — an extension, which runs in `up(app)`, could not. `app.use` already adds a
19
+ * middleware this late; this is its dual, and the pair is *participate* / *watch*.
20
+ */
21
+ add(observer: DispatchObserver): () => void {
22
+ this.observers.push(observer);
23
+
24
+ return () => {
25
+ const at = this.observers.indexOf(observer);
26
+ if (at !== -1) this.observers.splice(at, 1);
27
+ };
28
+ }
9
29
 
10
30
  publish(event: DispatchEvent): void {
11
31
  for (const observer of this.observers) {
@@ -10,10 +10,6 @@ export class InFlight {
10
10
  return this.running;
11
11
  }
12
12
 
13
- get open(): boolean {
14
- return this.accepting;
15
- }
16
-
17
13
  enter(entity: string, operation: string): () => void {
18
14
  if (!this.accepting) {
19
15
  throw new FougereError({
@@ -6,6 +6,7 @@
6
6
  * container, filesystem, migration or handler construction is involved.
7
7
  */
8
8
  import { lowerFirst, type SchemaView } from '@fougere/schema';
9
+ import { statementDrift } from './boot/statement-drift.js';
9
10
  import type { BindingPlan } from './boot/binding.js';
10
11
  import { targetOf } from './prefab/prefab.js';
11
12
  import type {
@@ -166,6 +167,9 @@ export function resolveEffectiveOperations(
166
167
  const effective = new Map<string, EffectiveOperation>();
167
168
  byHandler.set(handler, effective);
168
169
  const contracts = resolveContracts(handler, frond.operationsOverrides, collectorNames);
170
+ // A statement wins over the scan on purpose; saying so out loud is what keeps the
171
+ // win from hiding a rename. Compared here, where both readings are in hand.
172
+ resolutionDiagnostics.push(...statementDrift(frond, handler));
169
173
 
170
174
  for (const [name, rawContract] of contracts) {
171
175
  const subject = `${handler.ctor.name}.${name}`;
package/src/index.ts CHANGED
@@ -31,7 +31,12 @@ export { RouteAddress } from './contract/RouteAddress.js';
31
31
  export type { RouteAddressInput } from './contract/RouteAddress.js';
32
32
  export { DispatchLifecycle } from './dispatch/DispatchLifecycle.js';
33
33
  export { Dispatcher } from './dispatch/Dispatcher.js';
34
- export type { DispatchEvent, DispatchObserver } from './dispatch/DispatchEvent.js';
34
+ // The class and not only its type: an observer RECEIVES these, so a package that folds
35
+ // them — or a test of one — has to be able to make one through the door.
36
+ export { DispatchEvent } from './dispatch/DispatchEvent.js';
37
+ export type { CallPage, CallRecord } from './contract/CallLog.js';
38
+ export { driftOf, agrees, explain, type CardDrift } from './wire/drift.js';
39
+ export type { DispatchObserver } from './dispatch/DispatchEvent.js';
35
40
  export type { DispatchPort } from './dispatch/DispatchPort.js';
36
41
  export {
37
42
  inferOperationKind,
@@ -62,7 +67,16 @@ export type {
62
67
  export type { ModuleLoader } from './loader.js';
63
68
  export { FougereError, ErrorCode, validationErrorsOf, type FougereErrorOptions } from './wire/errors.js';
64
69
  export type { OperationContext, AppNext, AppMiddleware } from './wire/middleware.js';
65
- export { createLocalRunner, createAppRunner, assertIdentityCard } from './wire/call.js';
70
+ export { createLocalRunner, createAppRunner, assertIdentityCard, RPC_ENTITY, MAX_BODY_BYTES } from './wire/call.js';
71
+
72
+ // A frond an app STATES, for a boot that will not scan. No disk, no `typescript` — which
73
+ // is why it sits here and not in `/node`, beside the scanner that reads a project.
74
+ export { frond } from './scan/declare.js';
75
+ export type { FrondDeclaration, Declared, DeclaredSubject } from './scan/declare.js';
76
+ // What this app would answer to `rpc.discover`. Served by the boot, and reached until now
77
+ // only by tests through a deep path — a consumer comparing its own card to a producer's
78
+ // had to dispatch a call to ask a question about itself.
79
+ export { identityCardOf } from './wire/call.js';
66
80
  export type {
67
81
  FrondCall, Transport, IdentityCard, CardOp, Facade, RpcAnswer,
68
82
  TopologyReport, FrondPlacement, Edge,
package/src/node.ts CHANGED
@@ -11,12 +11,14 @@
11
11
  */
12
12
  export { boot } from './boot/boot.js';
13
13
 
14
- export { scanProject, frondAliases } from './scan/scanner.js';
14
+ export { scanProject, frondAliases, watchPathsOf } from './scan/scanner.js';
15
15
  export {
16
16
  type Conventions, type ConventionsInput, DEFAULT_CONVENTIONS,
17
- resolveConventions, frondPackage, frondDirsOf, providerDirsOf, watchPathsOf,
17
+ resolveConventions, frondPackage, frondDirsOf, providerDirsOf,
18
18
  } from './scan/conventions.js';
19
+ export { RUNTIME_PACKAGES } from './scan/bundling.js';
19
20
  export { emitScan, type EmitOptions } from './scan/emit.js';
21
+ export { emitStatement } from './scan/statement.js';
20
22
  export { setModuleLoader, getModuleLoader } from './loader.js';
21
23
  export { loadConfig, loadCascadedConfig } from './config-loader.js';
22
24
  export { defineFrond } from './frond-config.js';
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What a Fougere boot loads at RUNTIME, so a bundler must leave it alone.
3
+ *
4
+ * Stated once because it was stated twice and the two had already drifted: the Vite plugin
5
+ * listed `@fougere/schema`, `@fougere/adapter-graphql` and `@fougere/auth-better`, the Next
6
+ * one did not, and BOTH omitted the two packages `@fougere/app`'s boot actually imports
7
+ * dynamically — `@fougere/defaults` and `@fougere/transport-http`. A list a bundler reads
8
+ * has no way to notice it is wrong: nothing fails at build time, the app fails at boot.
9
+ *
10
+ * It sits here rather than beside the dynamic imports because both readers are BUILD-TIME
11
+ * plugins that already depend on core, and neither may pull the boot into a bundler config.
12
+ */
13
+ export const RUNTIME_PACKAGES: readonly string[] = [
14
+ '@fougere/app',
15
+ '@fougere/core',
16
+ '@fougere/schema',
17
+ '@fougere/defaults',
18
+ '@fougere/transport-http',
19
+ '@fougere/adapter-sql',
20
+ '@fougere/adapter-graphql',
21
+ '@fougere/auth-better',
22
+ 'better-sqlite3',
23
+ 'jiti',
24
+ 'typescript',
25
+ ];
@@ -6,7 +6,6 @@
6
6
  * literals in five packages, so a project could not move any of them and `frondsDir` was
7
7
  * a declared key with no reader.
8
8
  */
9
- import { join } from 'node:path';
10
9
 
11
10
  export interface Conventions {
12
11
  /**
@@ -103,20 +102,3 @@ export function frondDirsOf(conventions: Conventions): string[] {
103
102
  entities, handlers, presenters, collectors, seeds, versions, ...providerDirsOf(conventions),
104
103
  ])];
105
104
  }
106
-
107
- /**
108
- * What changes when a frond's domain changes — the paths a dev loop watches.
109
- *
110
- * The root frond IS the scan root, so watching its path would match every write in the
111
- * project: `.nuxt/`, `node_modules/`, the build output. Its convention directories are
112
- * the frond, and they are what a scan re-reads.
113
- */
114
- export function watchPathsOf(
115
- frond: { source: { path: string } },
116
- scanRoot: string,
117
- conventions: Conventions,
118
- ): string[] {
119
- return frond.source.path === scanRoot
120
- ? frondDirsOf(conventions).map((dir) => join(scanRoot, dir))
121
- : [frond.source.path];
122
- }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * A frond stated by its author, for an app that will not scan.
3
+ *
4
+ * The scan reads the type checker because a signature IS a declaration — and that reading
5
+ * has to happen while the types still exist, which is build time. An app that wants
6
+ * neither a build step nor `typescript` at runtime says the same thing here instead, and
7
+ * `createApp` cannot tell the difference: it consumes a `ScanResult`, never a scanner.
8
+ *
9
+ * What it does NOT ask for is the point. Every name the scan derives from a class is
10
+ * derived here the same way — `PostHandler` answers at `post`, `Post` is stored as `post`
11
+ * — so a declaration states classes and nothing else. `filePath` is empty because there is
12
+ * no file to point at, and a diagnostic that would have quoted one says so plainly.
13
+ *
14
+ * Measured on `demos/nuxt-blog`: 23 of its 29 operations need no word here at all, because
15
+ * `Crud.__ops` declares them at runtime. Only the six the author wrote by hand cost
16
+ * anything, and they cost `frond.config.ts` — where what is not derivable is stated.
17
+ */
18
+ import { lowerFirst, type SchemaView } from '@fougere/schema';
19
+ import type {
20
+ CollectorEntry, EntityEntry, FrondDescriptor, HandlerEntry,
21
+ PresenterEntry, ProviderEntry, SeedEntry,
22
+ } from './frond.js';
23
+ import { DEFAULT_CONVENTIONS } from './conventions.js';
24
+ import { getPresenterFields } from '../prefab/presenter.js';
25
+
26
+ /** A class, as a declaration hands it over: the constructor itself. */
27
+ type Ctor = new (...args: never[]) => unknown;
28
+
29
+ /**
30
+ * What a subject needs beyond its class, when its constructor names a frame or a port.
31
+ *
32
+ * `deps` is the one thing no runtime can recover: TypeScript erases the parameter types,
33
+ * and `registerFrames` reads them to know which frames to build — asking for one IS
34
+ * declaring it. A handler that takes only its own entity's ORM needs nothing here.
35
+ */
36
+ export interface DeclaredSubject {
37
+ ctor: Ctor;
38
+ deps?: string[];
39
+ }
40
+
41
+ /** A handler, and the surface it answers on when it is not the default one. */
42
+ export interface DeclaredHandler extends DeclaredSubject {
43
+ /**
44
+ * The scan reads this from the directory (`handlers/public/`), so a statement has to
45
+ * say it: two handlers over one entity collide on their address otherwise, and the
46
+ * refusal names the same route twice.
47
+ */
48
+ surface?: string;
49
+ }
50
+
51
+ /** A class on its own, or a class with what it asks for. */
52
+ export type Declared = Ctor | DeclaredSubject;
53
+
54
+ const ctorOf = (d: Declared): Ctor => (typeof d === 'function' ? d : d.ctor);
55
+
56
+ /**
57
+ * What a prefab was BUILT ON — `Presenter(Post)` and `Collector(User)` both keep it under
58
+ * `__entity`, which is the only place it survives: nothing in the FORM of `PostPresenter`
59
+ * says `Post`. Absent, the class did not come through the prefab, and the refusal says so
60
+ * rather than yielding a frond whose presenter belongs to no entity.
61
+ */
62
+ function subjectOf(ctor: Ctor, kind: string): { name: string } {
63
+ const subject = (ctor as unknown as { __entity?: { name: string } }).__entity;
64
+ if (!subject?.name) {
65
+ throw new Error(
66
+ `${ctor.name} is declared as a ${kind} but does not extend ${kind === 'presenter' ? 'Presenter(Entity)' : 'Collector(Type)'}, `
67
+ + `so what it is about cannot be read.`,
68
+ );
69
+ }
70
+
71
+ return subject;
72
+ }
73
+ const depsOf = (d: Declared): string[] => (typeof d === 'function' ? [] : d.deps ?? []);
74
+
75
+ /** `PostHandler` answers at `post` — the same rule the scan applies to a file it found. */
76
+ function addressOf(className: string): string {
77
+ const base = className.endsWith('Handler') ? className.slice(0, -7) : className;
78
+
79
+ return lowerFirst(base);
80
+ }
81
+
82
+ /** What a declaration states about one frond. Everything else is derived from the classes. */
83
+ export interface FrondDeclaration {
84
+ entities?: SchemaView[];
85
+ handlers?: (Ctor | DeclaredHandler)[];
86
+ presenters?: (Ctor | DeclaredSubject)[];
87
+ collectors?: (Ctor | DeclaredSubject)[];
88
+ providers?: Declared[];
89
+ seeds?: { entityName: string; data: SeedEntry['data'] }[];
90
+ /** Per-surface entity lists — the same key `frond.config.ts` states. */
91
+ surfaces?: Record<string, string[]>;
92
+ /** The import scope this frond answers under. Defaults to the conventional one. */
93
+ scope?: string;
94
+ }
95
+
96
+ /**
97
+ * State a frond without reading a disk.
98
+ *
99
+ * ```ts
100
+ * createApp({
101
+ * scan: { fronds: Fronds.scanned([frond('blog', { entities: [Post], handlers: [PostHandler] })]), diagnostics: [] },
102
+ * })
103
+ * ```
104
+ */
105
+ export function frond(name: string, declared: FrondDeclaration = {}): FrondDescriptor {
106
+ const scope = declared.scope ?? DEFAULT_CONVENTIONS.scope;
107
+
108
+ const entities: EntityEntry[] = (declared.entities ?? []).map((entityClass) => ({
109
+ name: lowerFirst((entityClass as { name: string }).name),
110
+ entityClass,
111
+ filePath: '',
112
+ exposed: true,
113
+ }));
114
+
115
+ const handlers: HandlerEntry[] = (declared.handlers ?? []).map((h) => {
116
+ const ctor = ctorOf(h);
117
+ const surface = typeof h === 'function' ? undefined : h.surface;
118
+ const address = addressOf(ctor.name);
119
+
120
+ return {
121
+ name: ctor.name,
122
+ address,
123
+ ctor,
124
+ // A handler about no stored row is ordinary — the address is not a promise that an
125
+ // entity carries it, which is why this is not looked up.
126
+ entityName: address,
127
+ operations: new Map(),
128
+ deps: depsOf(h),
129
+ filePath: '',
130
+ exposed: true,
131
+ ...(surface ? { surface } : {}),
132
+ };
133
+ });
134
+
135
+ // Read off the class, never restated: `Presenter(Post)` keeps `Post` in `__entity` and a
136
+ // computed field IS a method, which `getPresenterFields` already reads from the prototype.
137
+ // Asking for `entityName` and `fields` made a statement copy what the class carries — and
138
+ // a copy that drifts silently, since nothing compares the two.
139
+ const presenters: PresenterEntry[] = (declared.presenters ?? []).map((p) => {
140
+ const ctor = ctorOf(p);
141
+ const fields = getPresenterFields(ctor);
142
+
143
+ return {
144
+ entityName: lowerFirst(subjectOf(ctor, 'presenter').name),
145
+ ctor,
146
+ fields,
147
+ fieldMeta: fields.map((field) => ({ name: field })),
148
+ deps: depsOf(p),
149
+ filePath: '',
150
+ };
151
+ });
152
+
153
+ // `Collector(User)` keeps `User` the same way — the type a handler names to receive it.
154
+ const collectors: CollectorEntry[] = (declared.collectors ?? []).map((c) => {
155
+ const ctor = ctorOf(c);
156
+
157
+ return {
158
+ typeName: subjectOf(ctor, 'collector').name,
159
+ ctor,
160
+ deps: depsOf(c),
161
+ filePath: '',
162
+ };
163
+ });
164
+
165
+ const providers: ProviderEntry[] = (declared.providers ?? []).map((p) => ({
166
+ ctor: ctorOf(p),
167
+ deps: depsOf(p),
168
+ filePath: '',
169
+ }));
170
+
171
+ const seeds: SeedEntry[] = (declared.seeds ?? []).map((s) => ({
172
+ entityName: s.entityName,
173
+ data: s.data,
174
+ filePath: '',
175
+ }));
176
+
177
+ return {
178
+ name,
179
+ source: { path: '', package: `${scope}/${name}` },
180
+ providers,
181
+ entities,
182
+ handlers,
183
+ presenters,
184
+ collectors,
185
+ seeds,
186
+ ...(declared.surfaces ? { surfaces: declared.surfaces } : {}),
187
+ };
188
+ }
@@ -800,7 +800,6 @@ function parseClassMethods(
800
800
  const cls = findDefaultClass(source);
801
801
  if (!cls) return { methods: [], unresolvedHeritage: unresolved };
802
802
 
803
- // Parse child class methods
804
803
  const childMethods = extractClassMethods(cls, source, skip, checker);
805
804
  const childNames = new Set(childMethods.map((m) => m.name));
806
805
 
@@ -458,7 +458,6 @@ async function toPresenterEntry(filePath: string): Promise<PresenterEntry | null
458
458
  const presenterParams = await ctorParamsOf(filePath);
459
459
  const deps = presenterParams.map((p) => depKeyOf(p.type));
460
460
 
461
- // Parse method return types from source
462
461
  let fieldMeta: PresenterEntry['fieldMeta'] = [];
463
462
  try {
464
463
  const parsed = await presenterMethodsOf(filePath);
@@ -756,3 +755,25 @@ export async function scanProject(
756
755
 
757
756
  return { fronds, diagnostics };
758
757
  }
758
+
759
+ /**
760
+ * What changes when a frond's domain changes — the paths a dev loop watches.
761
+ *
762
+ * It lives beside the scan rather than beside the conventions it reads, because it turns
763
+ * names into DISK paths: `join` is `node:path`, and `conventions.ts` is reached from
764
+ * `index` through `frond()`, which an edge bundle imports. Measured — esbuild refused the
765
+ * Worker with `Could not resolve "node:path"`.
766
+ *
767
+ * The root frond IS the scan root, so watching its path would match every write in the
768
+ * project: `.nuxt/`, `node_modules/`, the build output. Its convention directories are
769
+ * the frond, and they are what a scan re-reads.
770
+ */
771
+ export function watchPathsOf(
772
+ frond: { source: { path: string } },
773
+ scanRoot: string,
774
+ conventions: Conventions,
775
+ ): string[] {
776
+ return frond.source.path === scanRoot
777
+ ? frondDirsOf(conventions).map((dir) => join(scanRoot, dir))
778
+ : [frond.source.path];
779
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The scan, written as the STATEMENT an author would have written.
3
+ *
4
+ * A scan runs at build and the app runs elsewhere — two runtimes, no shared memory — so
5
+ * something has to cross. What used to cross was the scan RESULT: every operation's
6
+ * TypeScript signature re-serialized as JSON, beside the five CRUD ops `Crud.__ops`
7
+ * declares at runtime anyway. A second writing of what the classes carry, and one that
8
+ * drifts, since nothing compares the two.
9
+ *
10
+ * What crosses here is what `fronds.ts` holds: imports that bring the ORIGINAL classes
11
+ * back, and `frond()` calls. Everything else `frond()` derives — `Post` is stored as
12
+ * `post`, `PostHandler` answers at `post`, a computed field IS a method, and
13
+ * `Presenter(Post)` keeps its subject. Only what TypeScript erases is named: a
14
+ * constructor's parameter types (`deps`), and the surface a handler answers on, which the
15
+ * scan read from its directory.
16
+ *
17
+ * Measured on demos/nuxt-blog: 102 lines of scan against 31 of statement, and the same app
18
+ * — same rows, same computed fields, and no `typescript` loaded at boot.
19
+ */
20
+ import { relative } from 'node:path';
21
+ import type { FrondDescriptor, ScanResult } from './frond.js';
22
+
23
+ type Live = { name?: string };
24
+
25
+ /**
26
+ * A file becomes the specifier the PROJECT already uses for it: `@fronds/blog/…`, the
27
+ * import scope a handler names its neighbour by, and which the Nuxt module registers as an
28
+ * alias for every frond it found.
29
+ *
30
+ * A relative path would name the same file by a second route, and two routes to one module
31
+ * are two modules — so `Post` would not equal `Post` and every identity check would fail
32
+ * quietly. It also puts the file outside what the alias resolves, which is what made Node,
33
+ * not the bundler, read the frond's `.ts` and answer 500 on `../entities/Post.js`.
34
+ *
35
+ * `.js`, because the source is `.ts` and the project spells a TypeScript source that way
36
+ * everywhere else — the alias resolves through the same rule as a hand-written import.
37
+ */
38
+ function specifierOf(filePath: string, frond: FrondDescriptor): string {
39
+ const inside = relative(frond.source.path, filePath).replace(/\.tsx?$/, '.js');
40
+
41
+ return `${frond.source.package}/${inside}`;
42
+ }
43
+
44
+ /** One alias per file, so a class imported twice is one binding and one identity. */
45
+ class Imports {
46
+ private readonly byPath = new Map<string, string>();
47
+ private readonly lines: string[] = [];
48
+
49
+ default(filePath: string, frond: FrondDescriptor): string {
50
+ const known = this.byPath.get(filePath);
51
+ if (known) return known;
52
+ const alias = `_${this.byPath.size}`;
53
+ this.lines.push(`import ${alias} from '${specifierOf(filePath, frond)}';`);
54
+ this.byPath.set(filePath, alias);
55
+
56
+ return alias;
57
+ }
58
+
59
+ render(): string {
60
+ return this.lines.join('\n');
61
+ }
62
+ }
63
+
64
+ /** `{ ctor: X, deps: [...] }` when there is something to say, the bare class otherwise. */
65
+ function subject(alias: string, deps: string[], extra = ''): string {
66
+ const parts = [
67
+ ...(deps.length ? [`deps: ${JSON.stringify(deps)}`] : []),
68
+ ...(extra ? [extra] : []),
69
+ ];
70
+
71
+ return parts.length ? `{ ctor: ${alias}, ${parts.join(', ')} }` : alias;
72
+ }
73
+
74
+ function frondOf(frond: FrondDescriptor, imports: Imports): string {
75
+ const list = (items: string[]): string => `[${items.join(', ')}]`;
76
+ const members: string[] = [];
77
+
78
+ if (frond.entities.length) {
79
+ members.push(`entities: ${list(frond.entities.map((e) => imports.default(e.filePath, frond)))}`);
80
+ }
81
+ if (frond.handlers.length) {
82
+ members.push(`handlers: ${list(frond.handlers.map((h) =>
83
+ subject(imports.default(h.filePath, frond), h.deps, h.surface ? `surface: ${JSON.stringify(h.surface)}` : '')))}`);
84
+ }
85
+ if (frond.presenters.length) {
86
+ members.push(`presenters: ${list(frond.presenters.map((p) => subject(imports.default(p.filePath, frond), p.deps)))}`);
87
+ }
88
+ if (frond.collectors.length) {
89
+ members.push(`collectors: ${list(frond.collectors.map((c) => subject(imports.default(c.filePath, frond), c.deps)))}`);
90
+ }
91
+ if (frond.providers.length) {
92
+ members.push(`providers: ${list(frond.providers.map((p) => subject(imports.default(p.filePath, frond), p.deps)))}`);
93
+ }
94
+ // A seed is DATA, not a class — the one member a statement cannot derive from an import.
95
+ if (frond.seeds.length) {
96
+ members.push(`seeds: ${JSON.stringify(frond.seeds.map((s) => ({ entityName: s.entityName, data: s.data })))}`);
97
+ }
98
+ if (frond.surfaces) members.push(`surfaces: ${JSON.stringify(frond.surfaces)}`);
99
+
100
+ const scope = (frond.source.package as string | undefined)?.split('/')[0];
101
+ if (scope) members.push(`scope: ${JSON.stringify(scope)}`);
102
+
103
+ return ` frond(${JSON.stringify(frond.name)}, {\n ${members.join(',\n ')},\n })`;
104
+ }
105
+
106
+ /** The scan as a list of `frond()` calls — the file an author writes, written for them. */
107
+ // Nothing is written relative to the destination, so where the file SITS is not a
108
+ // parameter — the alias resolves the same from anywhere.
109
+ export function emitStatement(scan: ScanResult): string {
110
+ const imports = new Imports();
111
+ const fronds = scan.fronds.map((f) => frondOf(f as FrondDescriptor & { source: Live }, imports));
112
+
113
+ return [
114
+ '// Generated by @fougere/nuxt — what the scan found, as the statement you would write.',
115
+ '//',
116
+ '// Classes are IMPORTED, never restated: `frond()` derives every name from them. Write',
117
+ '// your own `fronds.ts` beside `fougere.config.ts` to state this by hand instead.',
118
+ "import { frond } from '@fougere/core';",
119
+ imports.render(),
120
+ '',
121
+ 'export default [',
122
+ `${fronds.join(',\n')},`,
123
+ '];',
124
+ '',
125
+ ].join('\n');
126
+ }
package/src/wire/call.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { Card, type SchemaDescriptor } from '@fougere/schema';
10
10
  import { factsAnnouncedBy } from '../emit.js';
11
- import type { InvocationContext } from './invocation.js';
11
+ import type { InvocationContext } from '../contract/Invocation.js';
12
12
  import { FougereError, ErrorCode } from './errors.js';
13
13
  import type { App } from '../boot/types.js';
14
14
  import { createTransportEntry } from '../entry/TransportEntry.js';
@@ -27,6 +27,18 @@ export interface FrondCall {
27
27
  export type Transport = (call: FrondCall, invocation: InvocationContext) => Promise<unknown>;
28
28
 
29
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
+
30
42
  export const RPC_ENTITY = 'rpc';
31
43
 
32
44
  /**