@fougere/core 0.4.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 (118) 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 +2 -0
  49. package/dist/node.d.ts.map +1 -1
  50. package/dist/node.js +2 -0
  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/declare.d.ts +71 -0
  57. package/dist/scan/declare.d.ts.map +1 -0
  58. package/dist/scan/declare.js +125 -0
  59. package/dist/scan/declare.js.map +1 -0
  60. package/dist/scan/handler-parser.js +0 -1
  61. package/dist/scan/handler-parser.js.map +1 -1
  62. package/dist/scan/scanner.d.ts.map +1 -1
  63. package/dist/scan/scanner.js +0 -1
  64. package/dist/scan/scanner.js.map +1 -1
  65. package/dist/scan/statement.d.ts +4 -0
  66. package/dist/scan/statement.d.ts.map +1 -0
  67. package/dist/scan/statement.js +112 -0
  68. package/dist/scan/statement.js.map +1 -0
  69. package/dist/wire/call.d.ts +12 -1
  70. package/dist/wire/call.d.ts.map +1 -1
  71. package/dist/wire/call.js +11 -0
  72. package/dist/wire/call.js.map +1 -1
  73. package/dist/wire/drift.d.ts +55 -0
  74. package/dist/wire/drift.d.ts.map +1 -0
  75. package/dist/wire/drift.js +121 -0
  76. package/dist/wire/drift.js.map +1 -0
  77. package/dist/wire/middleware.d.ts +1 -1
  78. package/dist/wire/middleware.d.ts.map +1 -1
  79. package/package.json +3 -3
  80. package/src/boot/Emissions.ts +1 -1
  81. package/src/boot/HandlerFacade.ts +1 -1
  82. package/src/boot/binding.ts +1 -1
  83. package/src/boot/bootstrap.ts +12 -4
  84. package/src/boot/hosted.ts +77 -0
  85. package/src/boot/ownership.ts +1 -4
  86. package/src/boot/remote.ts +1 -1
  87. package/src/boot/statement-drift.ts +60 -0
  88. package/src/boot/types.ts +15 -2
  89. package/src/contract/CallLog.ts +55 -0
  90. package/src/contract.ts +12 -3
  91. package/src/dispatch/DispatchLifecycle.ts +22 -2
  92. package/src/dispatch/InFlight.ts +0 -4
  93. package/src/effective-operation.ts +4 -0
  94. package/src/index.ts +16 -2
  95. package/src/node.ts +2 -0
  96. package/src/scan/bundling.ts +25 -0
  97. package/src/scan/declare.ts +188 -0
  98. package/src/scan/handler-parser.ts +0 -1
  99. package/src/scan/scanner.ts +0 -1
  100. package/src/scan/statement.ts +126 -0
  101. package/src/wire/call.ts +13 -1
  102. package/src/wire/drift.ts +131 -0
  103. package/src/wire/middleware.ts +1 -1
  104. package/dist/boot/Lifecycle.d.ts +0 -4
  105. package/dist/boot/Lifecycle.d.ts.map +0 -1
  106. package/dist/boot/Lifecycle.js +0 -3
  107. package/dist/boot/Lifecycle.js.map +0 -1
  108. package/dist/scan/scan-cache.d.ts +0 -16
  109. package/dist/scan/scan-cache.d.ts.map +0 -1
  110. package/dist/scan/scan-cache.js +0 -99
  111. package/dist/scan/scan-cache.js.map +0 -1
  112. package/dist/wire/invocation.d.ts +0 -4
  113. package/dist/wire/invocation.d.ts.map +0 -1
  114. package/dist/wire/invocation.js +0 -3
  115. package/dist/wire/invocation.js.map +0 -1
  116. package/src/boot/Lifecycle.ts +0 -3
  117. package/src/scan/scan-cache.ts +0 -115
  118. package/src/wire/invocation.ts +0 -10
@@ -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
@@ -16,7 +16,9 @@ export {
16
16
  type Conventions, type ConventionsInput, DEFAULT_CONVENTIONS,
17
17
  resolveConventions, frondPackage, frondDirsOf, providerDirsOf, watchPathsOf,
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
+ ];
@@ -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);
@@ -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
  /**
@@ -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
+ }
@@ -20,7 +20,7 @@ export interface OperationContext {
20
20
  /** Extensible bag — middlewares deposit data here (user, permissions, etc.). */
21
21
  state: Record<string, unknown>;
22
22
  /** Transport-agnostic invocation context (params, query, body, state). */
23
- invocation?: import('./invocation.js').InvocationContext;
23
+ invocation?: import('../contract/Invocation.js').InvocationContext;
24
24
  }
25
25
 
26
26
  export type AppNext = () => Promise<unknown>;
@@ -1,4 +0,0 @@
1
- /** Compatibility module. Prefer AppLifecycle for the process-level lifecycle. */
2
- export { AppLifecycle, AppLifecycle as Lifecycle, migrating } from './AppLifecycle.js';
3
- export type { Extension } from './AppLifecycle.js';
4
- //# sourceMappingURL=Lifecycle.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Lifecycle.d.ts","sourceRoot":"","sources":["../../src/boot/Lifecycle.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,OAAO,EAAE,YAAY,EAAE,YAAY,IAAI,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACvF,YAAY,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC"}
@@ -1,3 +0,0 @@
1
- /** Compatibility module. Prefer AppLifecycle for the process-level lifecycle. */
2
- export { AppLifecycle, AppLifecycle as Lifecycle, migrating } from './AppLifecycle.js';
3
- //# sourceMappingURL=Lifecycle.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Lifecycle.js","sourceRoot":"","sources":["../../src/boot/Lifecycle.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,OAAO,EAAE,YAAY,EAAE,YAAY,IAAI,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC"}
@@ -1,16 +0,0 @@
1
- export declare function setCacheRoot(dir: string): void;
2
- /**
3
- * The parse of a file, or the parse again if the file changed — the one gesture, so a
4
- * caller never assembles the pieces.
5
- *
6
- * It handed out `hashFile` + `getCached` + `setCached` and every caller wrote the same
7
- * five lines in the same order; the scanner wrote them three times, once inline inside a
8
- * `try`. Three assemblies of one question, each free to forget the half that stores.
9
- *
10
- * `key` and not the path alone: one file is parsed several ways (its constructor, its
11
- * methods, its methods with heritage resolved) and each reading is its own entry.
12
- */
13
- export declare function cachedParse<T>(key: string, filePath: string, parse: () => Promise<T>): Promise<T>;
14
- /** Write cache to disk if modified. */
15
- export declare function flushCache(): void;
16
- //# sourceMappingURL=scan-cache.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"scan-cache.d.ts","sourceRoot":"","sources":["../../src/scan/scan-cache.ts"],"names":[],"mappings":"AAqDA,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAG9C;AA2BD;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAUvG;AAED,uCAAuC;AACvC,wBAAgB,UAAU,IAAI,IAAI,CAOjC"}