@fougere/core 0.8.2-alpha.0 → 0.8.4-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 (222) hide show
  1. package/dist/boot/Emissions.d.ts.map +1 -1
  2. package/dist/boot/Emissions.js +3 -3
  3. package/dist/boot/Emissions.js.map +1 -1
  4. package/dist/boot/auth.d.ts +1 -1
  5. package/dist/boot/auth.d.ts.map +1 -1
  6. package/dist/boot/bootstrap.d.ts.map +1 -1
  7. package/dist/boot/bootstrap.js +339 -595
  8. package/dist/boot/bootstrap.js.map +1 -1
  9. package/dist/boot/card.js +1 -1
  10. package/dist/boot/card.js.map +1 -1
  11. package/dist/boot/hosted.d.ts +1 -1
  12. package/dist/boot/hosted.d.ts.map +1 -1
  13. package/dist/boot/install.d.ts +40 -0
  14. package/dist/boot/install.d.ts.map +1 -0
  15. package/dist/boot/install.js +298 -0
  16. package/dist/boot/install.js.map +1 -0
  17. package/dist/boot/ownership.d.ts +1 -1
  18. package/dist/boot/ownership.d.ts.map +1 -1
  19. package/dist/boot/ownership.js +14 -1
  20. package/dist/boot/ownership.js.map +1 -1
  21. package/dist/boot/seed.d.ts +7 -1
  22. package/dist/boot/seed.d.ts.map +1 -1
  23. package/dist/boot/seed.js +7 -2
  24. package/dist/boot/seed.js.map +1 -1
  25. package/dist/boot/statement-drift.d.ts +1 -1
  26. package/dist/boot/statement-drift.d.ts.map +1 -1
  27. package/dist/boot/together.d.ts +1 -1
  28. package/dist/boot/together.d.ts.map +1 -1
  29. package/dist/boot/together.js +1 -1
  30. package/dist/boot/together.js.map +1 -1
  31. package/dist/boot/types.d.ts +2 -2
  32. package/dist/boot/types.d.ts.map +1 -1
  33. package/dist/config-loader.d.ts +1 -1
  34. package/dist/config-loader.d.ts.map +1 -1
  35. package/dist/contract.d.ts +2 -2
  36. package/dist/contract.d.ts.map +1 -1
  37. package/dist/contract.js +1 -1
  38. package/dist/contract.js.map +1 -1
  39. package/dist/conventions.d.ts.map +1 -0
  40. package/dist/conventions.js.map +1 -0
  41. package/dist/crypto/encoding.d.ts +0 -1
  42. package/dist/crypto/encoding.d.ts.map +1 -1
  43. package/dist/crypto/encoding.js +0 -7
  44. package/dist/crypto/encoding.js.map +1 -1
  45. package/dist/declare.js +1 -1
  46. package/dist/declare.js.map +1 -1
  47. package/dist/descriptor/index.d.ts +15 -0
  48. package/dist/descriptor/index.d.ts.map +1 -0
  49. package/dist/descriptor/index.js +14 -0
  50. package/dist/descriptor/index.js.map +1 -0
  51. package/dist/descriptor/surface.d.ts +8 -0
  52. package/dist/descriptor/surface.d.ts.map +1 -0
  53. package/dist/descriptor/surface.js +16 -0
  54. package/dist/descriptor/surface.js.map +1 -0
  55. package/dist/dispatch/HandlerFacade.d.ts +2 -0
  56. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  57. package/dist/dispatch/HandlerFacade.js +7 -2
  58. package/dist/dispatch/HandlerFacade.js.map +1 -1
  59. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  60. package/dist/dispatch/StorageGuard.js +9 -23
  61. package/dist/dispatch/StorageGuard.js.map +1 -1
  62. package/dist/dispatch/presenterArguments.d.ts +7 -1
  63. package/dist/dispatch/presenterArguments.d.ts.map +1 -1
  64. package/dist/dispatch/presenterArguments.js +15 -4
  65. package/dist/dispatch/presenterArguments.js.map +1 -1
  66. package/dist/dispatch/validateInput.d.ts.map +1 -1
  67. package/dist/dispatch/validateInput.js +2 -2
  68. package/dist/dispatch/validateInput.js.map +1 -1
  69. package/dist/effective-operation.d.ts +1 -1
  70. package/dist/effective-operation.d.ts.map +1 -1
  71. package/dist/effective-operation.js +2 -12
  72. package/dist/effective-operation.js.map +1 -1
  73. package/dist/index.d.ts +7 -9
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +4 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/node.d.ts +2 -12
  78. package/dist/node.d.ts.map +1 -1
  79. package/dist/node.js +2 -12
  80. package/dist/node.js.map +1 -1
  81. package/dist/prefab/crud.d.ts +1 -1
  82. package/dist/prefab/crud.d.ts.map +1 -1
  83. package/dist/prefab/mirror.d.ts +10 -11
  84. package/dist/prefab/mirror.d.ts.map +1 -1
  85. package/dist/prefab/mirror.js +1 -27
  86. package/dist/prefab/mirror.js.map +1 -1
  87. package/dist/prefab/repository.d.ts +1 -1
  88. package/dist/prefab/repository.d.ts.map +1 -1
  89. package/dist/{scan/result.d.ts → scan.d.ts} +2 -2
  90. package/dist/scan.d.ts.map +1 -0
  91. package/dist/scan.js +2 -0
  92. package/dist/scan.js.map +1 -0
  93. package/dist/source.d.ts +1 -1
  94. package/dist/source.d.ts.map +1 -1
  95. package/dist/storage/criterion.d.ts.map +1 -0
  96. package/dist/{criterion.js → storage/criterion.js} +2 -2
  97. package/dist/storage/criterion.js.map +1 -0
  98. package/dist/{storage.d.ts → storage/port.d.ts} +6 -3
  99. package/dist/storage/port.d.ts.map +1 -0
  100. package/dist/{storage.js → storage/port.js} +13 -6
  101. package/dist/storage/port.js.map +1 -0
  102. package/dist/{store.d.ts → storage/store.d.ts} +1 -1
  103. package/dist/storage/store.d.ts.map +1 -0
  104. package/dist/{store.js → storage/store.js} +33 -17
  105. package/dist/storage/store.js.map +1 -0
  106. package/dist/verify.js +1 -1
  107. package/dist/verify.js.map +1 -1
  108. package/dist/wire/emit.d.ts.map +1 -0
  109. package/dist/wire/emit.js.map +1 -0
  110. package/dist/wire/errors.js +2 -2
  111. package/dist/wire/errors.js.map +1 -1
  112. package/package.json +10 -4
  113. package/src/boot/Emissions.ts +3 -3
  114. package/src/boot/auth.ts +1 -1
  115. package/src/boot/bootstrap.ts +386 -674
  116. package/src/boot/card.ts +1 -1
  117. package/src/boot/hosted.ts +1 -1
  118. package/src/boot/install.ts +390 -0
  119. package/src/boot/ownership.ts +17 -2
  120. package/src/boot/seed.ts +18 -3
  121. package/src/boot/statement-drift.ts +1 -1
  122. package/src/boot/together.ts +1 -1
  123. package/src/boot/types.ts +2 -2
  124. package/src/config-loader.ts +1 -1
  125. package/src/contract.ts +2 -2
  126. package/src/crypto/encoding.ts +0 -7
  127. package/src/declare.ts +1 -1
  128. package/src/descriptor/index.ts +15 -0
  129. package/src/descriptor/surface.ts +23 -0
  130. package/src/dispatch/HandlerFacade.ts +9 -2
  131. package/src/dispatch/StorageGuard.ts +8 -21
  132. package/src/dispatch/presenterArguments.ts +21 -9
  133. package/src/dispatch/validateInput.ts +2 -2
  134. package/src/effective-operation.ts +3 -14
  135. package/src/index.ts +10 -9
  136. package/src/node.ts +2 -16
  137. package/src/prefab/crud.ts +1 -1
  138. package/src/prefab/mirror.ts +12 -38
  139. package/src/prefab/repository.ts +1 -1
  140. package/src/{scan/result.ts → scan.ts} +1 -1
  141. package/src/source.ts +1 -1
  142. package/src/{criterion.ts → storage/criterion.ts} +2 -2
  143. package/src/{storage.ts → storage/port.ts} +20 -5
  144. package/src/{store.ts → storage/store.ts} +35 -16
  145. package/src/verify.ts +1 -1
  146. package/src/wire/call.ts +1 -1
  147. package/src/wire/errors.ts +2 -2
  148. package/dist/boot/boot.d.ts +0 -45
  149. package/dist/boot/boot.d.ts.map +0 -1
  150. package/dist/boot/boot.js +0 -63
  151. package/dist/boot/boot.js.map +0 -1
  152. package/dist/criterion.d.ts.map +0 -1
  153. package/dist/criterion.js.map +0 -1
  154. package/dist/declarations.d.ts +0 -29
  155. package/dist/declarations.d.ts.map +0 -1
  156. package/dist/declarations.js +0 -69
  157. package/dist/declarations.js.map +0 -1
  158. package/dist/emit.d.ts.map +0 -1
  159. package/dist/emit.js.map +0 -1
  160. package/dist/imports.d.ts +0 -16
  161. package/dist/imports.d.ts.map +0 -1
  162. package/dist/imports.js +0 -78
  163. package/dist/imports.js.map +0 -1
  164. package/dist/placement.d.ts +0 -25
  165. package/dist/placement.d.ts.map +0 -1
  166. package/dist/placement.js +0 -69
  167. package/dist/placement.js.map +0 -1
  168. package/dist/scan/adapters.d.ts +0 -3
  169. package/dist/scan/adapters.d.ts.map +0 -1
  170. package/dist/scan/adapters.js +0 -25
  171. package/dist/scan/adapters.js.map +0 -1
  172. package/dist/scan/bundling.d.ts +0 -3
  173. package/dist/scan/bundling.d.ts.map +0 -1
  174. package/dist/scan/bundling.js +0 -15
  175. package/dist/scan/bundling.js.map +0 -1
  176. package/dist/scan/contract.d.ts +0 -27
  177. package/dist/scan/contract.d.ts.map +0 -1
  178. package/dist/scan/contract.js +0 -46
  179. package/dist/scan/contract.js.map +0 -1
  180. package/dist/scan/conventions.d.ts.map +0 -1
  181. package/dist/scan/conventions.js.map +0 -1
  182. package/dist/scan/emit.d.ts +0 -10
  183. package/dist/scan/emit.d.ts.map +0 -1
  184. package/dist/scan/emit.js +0 -144
  185. package/dist/scan/emit.js.map +0 -1
  186. package/dist/scan/handler-parser.d.ts +0 -26
  187. package/dist/scan/handler-parser.d.ts.map +0 -1
  188. package/dist/scan/handler-parser.js +0 -607
  189. package/dist/scan/handler-parser.js.map +0 -1
  190. package/dist/scan/result.d.ts.map +0 -1
  191. package/dist/scan/result.js +0 -2
  192. package/dist/scan/result.js.map +0 -1
  193. package/dist/scan/scanner.d.ts +0 -12
  194. package/dist/scan/scanner.d.ts.map +0 -1
  195. package/dist/scan/scanner.js +0 -569
  196. package/dist/scan/scanner.js.map +0 -1
  197. package/dist/scan/statement.d.ts +0 -4
  198. package/dist/scan/statement.d.ts.map +0 -1
  199. package/dist/scan/statement.js +0 -135
  200. package/dist/scan/statement.js.map +0 -1
  201. package/dist/storage.d.ts.map +0 -1
  202. package/dist/storage.js.map +0 -1
  203. package/dist/store.d.ts.map +0 -1
  204. package/dist/store.js.map +0 -1
  205. package/src/boot/boot.ts +0 -110
  206. package/src/declarations.ts +0 -102
  207. package/src/imports.ts +0 -94
  208. package/src/placement.ts +0 -86
  209. package/src/scan/adapters.ts +0 -32
  210. package/src/scan/bundling.ts +0 -14
  211. package/src/scan/contract.ts +0 -80
  212. package/src/scan/emit.ts +0 -160
  213. package/src/scan/handler-parser.ts +0 -695
  214. package/src/scan/scanner.ts +0 -694
  215. package/src/scan/statement.ts +0 -159
  216. /package/dist/{scan/conventions.d.ts → conventions.d.ts} +0 -0
  217. /package/dist/{scan/conventions.js → conventions.js} +0 -0
  218. /package/dist/{criterion.d.ts → storage/criterion.d.ts} +0 -0
  219. /package/dist/{emit.d.ts → wire/emit.d.ts} +0 -0
  220. /package/dist/{emit.js → wire/emit.js} +0 -0
  221. /package/src/{scan/conventions.ts → conventions.ts} +0 -0
  222. /package/src/{emit.ts → wire/emit.ts} +0 -0
@@ -1,694 +0,0 @@
1
- import { readdir, readFile } from 'node:fs/promises';
2
- import { existsSync, type Dirent } from 'node:fs';
3
- import { join, dirname, basename, resolve as resolvePath } from 'node:path';
4
- import type { FrondDescriptor, ProviderEntry, EntityEntry, HandlerEntry, PresenterEntry, CollectorEntry, SeedEntry } from '../descriptor/frond.js';
5
- import type { ScanResult, ScanDiagnostic } from './result.js';
6
- import { ANONYMOUS_SCHEMA_NAME, type SchemaView } from '@fougere/schema';
7
- import type { OperationContract, OperationsMap } from '../wire/operation.js';
8
- import { cardinalityOf } from '../wire/operation.js';
9
- import { computeBindingPlan } from '../wire/binding.js';
10
- import {
11
- parseAllHandlerMethods,
12
- parsePresenterMethods,
13
- parseConstructorParams,
14
- resetTypePrograms,
15
- seedTypeProgram,
16
- } from './handler-parser.js';
17
- import type { TypeRef } from '../wire/signature.js';
18
- import { loadFrondConfig } from '../frond-config.js';
19
- import { emitKeyOf } from '../emit.js';
20
- import { getPresenterFields } from '../prefab/presenter.js';
21
- import { storageKeyOf, togetherKeyOf } from '../storage.js';
22
- import { targetOf, viewsOf, outputOf } from '../prefab/prefab.js';
23
- import { ownedBy, repositoryKeyOf } from '../prefab/repository.js';
24
- import { lowerFirst } from '@fougere/schema';
25
- import { Fronds } from '../descriptor/Fronds.js';
26
- import { getModuleLoader } from '../loader.js';
27
- import {
28
- type Conventions, type ConventionsInput,
29
- DEFAULT_CONVENTIONS, resolveConventions, frondPackage, providerDirsOf, frondDirsOf,
30
- } from './conventions.js';
31
-
32
- // FS
33
-
34
- /** What this scan run could not do. */
35
- let diagnostics: ScanDiagnostic[] = [];
36
-
37
- function record(d: ScanDiagnostic): void {
38
- diagnostics.push(d);
39
- }
40
-
41
- /**
42
- * An absent convention directory is the ordinary case — a frond without `presenters/` is not a
43
- * defect.
44
- */
45
- async function readEntries(path: string): Promise<Dirent[]> {
46
- try {
47
- return await readdir(path, { withFileTypes: true });
48
- } catch (cause) {
49
- if ((cause as NodeJS.ErrnoException)?.code === 'ENOENT') return [];
50
- record({
51
- severity: 'blocking',
52
- code: 'directory-unreadable',
53
- filePath: path,
54
- // The path is NOT repeated here: `filePath` carries it, and a renderer that
55
- // prints both wraps an absolute path twice into an unreadable box.
56
- message: 'Could not read this directory — anything it declares is missing from '
57
- + 'the app, and nothing downstream can tell that from an empty directory.',
58
- cause,
59
- });
60
- return [];
61
- }
62
- }
63
-
64
- async function dirs(path: string): Promise<string[]> {
65
- const entries = await readEntries(path);
66
- return entries.filter((e) => e.isDirectory()).map((e) => e.name);
67
- }
68
-
69
- async function files(path: string): Promise<string[]> {
70
- const entries = await readEntries(path);
71
- return entries
72
- .filter((e) => e.isFile() && (e.name.endsWith('.ts') || e.name.endsWith('.js')))
73
- .map((e) => join(path, e.name));
74
- }
75
-
76
- // Module
77
-
78
- async function loadModule(filePath: string): Promise<Record<string, unknown>> {
79
- return await getModuleLoader()(filePath);
80
- }
81
-
82
- async function loadDefault(filePath: string): Promise<unknown> {
83
- const mod = await loadModule(filePath);
84
- return mod.default;
85
- }
86
-
87
- async function loadClass(filePath: string): Promise<ProviderEntry['ctor']> {
88
- const ctor = await loadDefault(filePath);
89
- // A convention directory holds classes, because a provider is registered under one and
90
- // asked for by its type. What is NOT a class — a shared contract, a pure function, a
91
- // table of constants — belongs beside them rather than among them, and saying where
92
- // costs one line: measured twice on a real project, both times a file that had to move.
93
- if (typeof ctor !== 'function' || !ctor.prototype) {
94
- throw new Error(
95
- `${filePath}: default export is not a class. This directory is scanned for providers, `
96
- + 'which are registered under a class name — a contract or a pure function has no key '
97
- + `to answer under. Move it beside the directory, at the frond's root, where the scan `
98
- + 'reads nothing and an import still reaches it.',
99
- );
100
- }
101
- return ctor as ProviderEntry['ctor'];
102
- }
103
-
104
- function isEntityClass(value: unknown): value is SchemaView {
105
- return typeof value === 'function' && 'getFields' in (value as any);
106
- }
107
-
108
- // Workspace
109
-
110
- /** Walk up from project root to find the monorepo root (pnpm-workspace.yaml + packages/). */
111
- function findWorkspaceRoot(from: string): string {
112
- let dir = resolvePath(from);
113
- while (dir !== dirname(dir)) {
114
- if (existsSync(join(dir, 'pnpm-workspace.yaml')) && existsSync(join(dir, 'packages'))) return dir;
115
- dir = dirname(dir);
116
- }
117
- return resolvePath(from); // fallback: use project root itself
118
- }
119
-
120
- /** Strip the 'Handler' suffix → the name the handler answers to. */
121
- function toAddress(className: string): string {
122
- const base = className.endsWith('Handler') ? className.slice(0, -7) : className;
123
- return lowerFirst(base);
124
- }
125
-
126
- // Scan
127
-
128
- /**
129
- * The container key a constructor parameter asks for — derived from its TYPE, not from how the
130
- * type was spelled.
131
- */
132
- function depKeyOf(type: TypeRef): string {
133
- // `Facade<PostHandler>` — the second port, read exactly like the first. The type names
134
- // what arrives: not the handler (its methods take positional arguments and it is never
135
- // injected), but the door built in front of it. Same key whether that door is the local
136
- // façade or a doublure, which is what makes the topology invisible from a signature.
137
- const facadeOf = type.name === 'Facade' ? type.generics?.[0]?.name : undefined;
138
- if (facadeOf) return lowerFirst(facadeOf);
139
-
140
- // `Emit<PostPublished>` — the third port, and the only one that names a SUBJECT rather
141
- // than an interlocutor. Read like the other two: the type names what arrives, here a
142
- // function that announces. Who receives it is not in the signature and never will be.
143
- const factOf = type.name === 'Emit' ? type.generics?.[0]?.name : undefined;
144
- if (factOf) return emitKeyOf(factOf);
145
-
146
- // `Together<[Account, Ledger], [RateMirror]>` — the fifth reading, and the only one whose
147
- // argument is a SET rather than one subject. The AST hands each tuple back as a single
148
- // string, so they are split here, where every other question of "how was it written" lives.
149
- const frame = type.name === 'Together' ? type.generics?.[0]?.name : undefined;
150
- if (frame) return togetherKeyOf(tupleMembers(frame), tupleMembers(type.generics?.[1]?.name ?? ''));
151
-
152
- // `RepositoryOf<Post>` — what an author writes when no repository file exists. The dual
153
- // of the line below: one names the port, the other the holder, and both resolve to a key
154
- // rather than to a class the author would have to invent.
155
- const subject = type.name === 'RepositoryOf' ? type.generics?.[0]?.name : undefined;
156
- if (subject) return repositoryKeyOf(subject);
157
-
158
- // The GENERIC names the subject, so a prefix does not change the key — `MeiliStorage<Card>`
159
- // is `Card`'s storage. A class named `FileStorage` is untouched: it carries no generic.
160
- const target = type.name.endsWith('Storage') && type.generics?.length === 1
161
- ? type.generics[0]?.name
162
- : undefined;
163
- if (!target) return type.name;
164
-
165
- return storageKeyOf(target);
166
- }
167
-
168
- /** `'[Account, Ledger]'` → `['Account', 'Ledger']`. */
169
- function tupleMembers(raw: string): string[] {
170
- return raw.replace(/^\[|\]$/g, '').split(',').map((member) => member.trim()).filter(Boolean);
171
- }
172
-
173
- /** These readings are semantic. */
174
- const ctorParamsOf = (filePath: string) =>
175
- parseConstructorParams(filePath);
176
-
177
- const presenterMethodsOf = (filePath: string) =>
178
- parsePresenterMethods(filePath);
179
-
180
- const handlerMethodsOf = (filePath: string, projectRoot?: string) =>
181
- parseAllHandlerMethods(filePath, projectRoot);
182
-
183
- async function toProvider(filePath: string): Promise<ProviderEntry> {
184
- const ctor = await loadClass(filePath);
185
- // Read here, while the class is the one the source declared. What a bundler does to
186
- // that name later is why it is carried rather than asked for again.
187
- const name = ctor.name;
188
- const params = await ctorParamsOf(filePath);
189
- const deps = params.map((p) => depKeyOf(p.type));
190
-
191
- // A repository inherits its constructor from `Repository(…)`, so the file declares none
192
- // and the scan reads no parameter. The mixin knows what it was built for and says so at
193
- // runtime — same escape as `Crud.__ops`, and the same reason: what a prefab fabricates,
194
- // only the prefab can describe.
195
- //
196
- // An AGGREGATE owns several, and its base takes them in the declared order. It is handed
197
- // no frame: the boundary and the unit of work are two statements, and a frame is ASKED FOR
198
- // like anywhere else — see `prefab/repository.ts`.
199
- const owned = ownedBy(ctor);
200
- const target = targetOf(ctor);
201
- if (owned.length > 1 && deps.length === 0) {
202
- deps.push(...owned.map((entity) => storageKeyOf(lowerFirst((entity as { name: string }).name))));
203
- } else if (target && deps.length === 0) {
204
- deps.push(storageKeyOf(lowerFirst((target as { name: string }).name)));
205
- }
206
-
207
- // `name` beside `ctor`, and it IS the registration key — what `depKeyOf` returns, since
208
- // it reads the type as written. It used to be asked of `ctor.name` at boot, which held
209
- // until a bundler lowered a static field and renamed the declaration doing it.
210
- return { name, ctor, deps, filePath };
211
- }
212
-
213
- async function toEntityEntry(filePath: string): Promise<EntityEntry | null> {
214
- const exported = await loadDefault(filePath);
215
- if (!isEntityClass(exported)) return null;
216
- const runtimeName = (exported as { name?: string }).name;
217
- // A derivation returned directly (`export default User.extend(...)`) carries the
218
- // factory's own name, which the schema package stamps and exports. The file is then
219
- // the declaration site and therefore the only name the author actually supplied;
220
- // named classes keep winning.
221
- const declaredName = runtimeName && runtimeName !== ANONYMOUS_SCHEMA_NAME
222
- ? runtimeName
223
- : basename(filePath).replace(/\.[^.]+$/, '');
224
- const name = lowerFirst(declaredName);
225
- return { name, entityClass: exported, filePath };
226
- }
227
-
228
- /**
229
- * Resolve a TypeRef to a runtime schema if available in module exports.
230
- * Handles arrays, generics (uses base name), and simple references.
231
- */
232
- function resolveSchema(type: TypeRef, moduleExports: Record<string, unknown>): SchemaView | undefined {
233
- // An array's element type IS `type.name` — the arity rides beside it, so nothing has
234
- // to be unwrapped here.
235
- // For generics like Pagination<Post>, also check inner types
236
- if (type.generics) {
237
- for (const g of type.generics) {
238
- const resolved = moduleExports[g.name];
239
- if (resolved && typeof resolved === 'function' && 'getFields' in resolved) {
240
- // `Partial<X>` in a signature IS the patch declaration (Crud.update) —
241
- // project it onto the schema view instead of dropping the wrapper, so
242
- // the facade validates in patch mode (absent field → untouched).
243
- if (type.name === 'Partial' && 'partial' in resolved && typeof (resolved as any).partial === 'function') {
244
- return (resolved as any).partial() as SchemaView;
245
- }
246
- return resolved as unknown as SchemaView;
247
- }
248
- }
249
- }
250
- const resolved = moduleExports[type.name];
251
- if (resolved && typeof resolved === 'function' && 'getFields' in resolved) {
252
- return resolved as unknown as SchemaView;
253
- }
254
- return undefined;
255
- }
256
-
257
- /** Parse ALL method signatures for unified binding. */
258
- async function inferOperations(
259
- filePath: string,
260
- handlerName: string,
261
- moduleExports: Record<string, unknown>,
262
- collectorTypeNames: Set<string>,
263
- explicitInputs: ReadonlySet<string>,
264
- declared: Record<string, OperationContract>,
265
- projectRoot?: string,
266
- ): Promise<OperationsMap> {
267
- const map = new Map<string, OperationContract>();
268
- let parsed: Awaited<ReturnType<typeof parseAllHandlerMethods>>;
269
- try {
270
- parsed = await handlerMethodsOf(filePath, projectRoot);
271
- } catch (cause) {
272
- // The handler still gets a façade — its methods exist at runtime — but with no
273
- // contract: no binding plan, no input schema, no doc sentence. It used to
274
- // return the empty map, so the app served a stranger's idea of the handler and
275
- // said nothing. The operations are gone; the sentence saying so is not.
276
- record({
277
- severity: 'blocking',
278
- code: 'handler-parse-failed',
279
- filePath,
280
- message: 'Could not parse this handler — its operations carry no contract, so the '
281
- + 'façade serves them unbound. Not the same as a handler with no operation.',
282
- cause,
283
- });
284
- return map;
285
- }
286
-
287
- /**
288
- * A base class the parse could not open — an installed package, typically, whose source is not
289
- * in the workspace.
290
- */
291
- for (const base of parsed.unresolvedHeritage) {
292
- record({
293
- severity: 'warning',
294
- code: 'heritage-unresolved',
295
- filePath,
296
- message: `Could not resolve 'extends ${base}' — any operation it declares is absent `
297
- + `from this façade, and the scan cannot tell that from a base class with none. `
298
- + `State the contract in frond.config.ts to put it back.`,
299
- });
300
- }
301
-
302
- for (const method of parsed.methods) {
303
- // The contract is what carries the description; `signature` is the raw material it
304
- // was read from. Leaving it only on the signature meant every consumer had to know
305
- // to look one level down, and only the façade did.
306
- const meta: OperationContract = {
307
- signature: method,
308
- ...(method.description && { description: method.description }),
309
- };
310
-
311
- // A convention may omit a declaration only when it has one answer. Only values the
312
- // caller supplies through the body are candidates: a schema-typed collector, fact or
313
- // context parameter is not input merely because it names an entity. The old loop
314
- // ignored provenance and assigned the first schema it met, so swapping two parameters
315
- // silently changed the contract the façade used to validate the request body.
316
- const binding = computeBindingPlan(method.params, collectorTypeNames);
317
- const candidates = method.params.flatMap((param, index) => {
318
- if (binding[index]?.source.kind !== 'input') return [];
319
- const schema = resolveSchema(param.type, moduleExports);
320
- return schema ? [{ param, schema }] : [];
321
- });
322
- if (candidates.length === 1) {
323
- meta.input = candidates[0].schema;
324
- } else if (
325
- candidates.length > 1
326
- && declared[method.name]?.input === undefined
327
- && !explicitInputs.has(method.name)
328
- ) {
329
- const subject = `${handlerName}.${method.name}`;
330
- record({
331
- severity: 'blocking',
332
- code: 'input-contract-ambiguous',
333
- filePath,
334
- subject,
335
- message: `Cannot infer the input contract for ${subject}: ${candidates.length} entity `
336
- + `candidates — ${candidates.map(({ param }) => `${param.name}: ${param.type.raw}`).join('; ')}. `
337
- + `Declare operations.${method.name}.input in frond.config.ts.`,
338
- });
339
- }
340
-
341
- if (method.returnType) {
342
- meta.output = resolveSchema(method.returnType, moduleExports);
343
- // `output` is the shape of one row; this says how many rows come back.
344
- meta.cardinality = cardinalityOf(method.returnType);
345
- }
346
-
347
- map.set(method.name, meta);
348
- }
349
-
350
- return map;
351
- }
352
-
353
- async function toHandlerEntry(
354
- filePath: string,
355
- entityByClassName: Map<string, SchemaView>,
356
- collectorTypeNames: Set<string>,
357
- explicitInputs: ReadonlySet<string>,
358
- projectRoot?: string,
359
- surface?: string,
360
- ): Promise<HandlerEntry> {
361
- const mod = await loadModule(filePath);
362
- const ctor = mod.default;
363
- if (typeof ctor !== 'function' || !ctor.prototype)
364
- throw new Error(`${filePath}: default export is not a class`);
365
-
366
- // Augment module exports with known entity classes so resolveSchema
367
- // can find them when T is substituted (e.g. T → Post after parsing Crud(Post))
368
- const augmented: Record<string, unknown> = { ...mod };
369
- for (const [className, entityClass] of entityByClassName) {
370
- if (!(className in augmented)) augmented[className] = entityClass;
371
- }
372
-
373
- const address = toAddress(ctor.name);
374
- const declaredOps = (ctor as { __ops?: Record<string, OperationContract> }).__ops ?? {};
375
- const operations = await inferOperations(
376
- filePath,
377
- ctor.name,
378
- augmented,
379
- collectorTypeNames,
380
- explicitInputs,
381
- declaredOps,
382
- projectRoot,
383
- );
384
- const ctorParams = await ctorParamsOf(filePath);
385
- const deps = ctorParams.map((p) => depKeyOf(p.type));
386
-
387
- // Read output override from Crud(Entity, Output) — static __output property
388
- // A handler-wide view, when it is not simply the entity — the two are compared by
389
- // identity, which is why both slots are read through the same door.
390
- const subject = targetOf(ctor);
391
- const declared = outputOf(ctor);
392
- const outputOverride = declared && subject && declared !== subject
393
- ? (declared as unknown as SchemaView)
394
- : undefined;
395
-
396
- return {
397
- name: lowerFirst(ctor.name),
398
- address,
399
- ctor: ctor as ProviderEntry['ctor'],
400
- operations,
401
- deps,
402
- filePath,
403
- outputOverride,
404
- surface,
405
- };
406
- }
407
-
408
- /** Strip '.seed' suffix → entity name. 'Author.seed.ts' → 'author'. */
409
- function toSeedEntityName(fileName: string): string {
410
- const base = fileName.replace(/\.seed\.(ts|js)$/, '').replace(/\.(ts|js)$/, '');
411
- return lowerFirst(base);
412
- }
413
-
414
- async function toSeedEntry(filePath: string): Promise<SeedEntry | null> {
415
- const data = await loadDefault(filePath);
416
- if (Array.isArray(data) || typeof data === 'function') {
417
- const fileName = filePath.split('/').pop()!;
418
- return { entityName: toSeedEntityName(fileName), data: data as SeedEntry['data'], filePath };
419
- }
420
- return null;
421
- }
422
-
423
- async function toPresenterEntry(filePath: string): Promise<PresenterEntry | null> {
424
- const ctor = await loadClass(filePath);
425
- const target = targetOf(ctor);
426
- if (!target) return null;
427
- const entityName = lowerFirst((target as any).name);
428
- const fields = getPresenterFields(ctor);
429
- const presenterParams = await ctorParamsOf(filePath);
430
- const deps = presenterParams.map((p) => depKeyOf(p.type));
431
-
432
- let fieldMeta: PresenterEntry['fieldMeta'] = [];
433
- try {
434
- const parsed = await presenterMethodsOf(filePath);
435
- fieldMeta = parsed.map((m) => ({
436
- name: m.name,
437
- returnType: m.returnType?.name,
438
- // One array level IS the page — the method answers one value per row. What is left
439
- // over is the field's own arity: `string[]` a scalar per row, `string[][]` a list.
440
- list: (m.returnType?.arrayDepth ?? 0) > 1,
441
- nullable: m.returnType?.nullable,
442
- // Everything after the rows is bound like a handler's argument.
443
- params: m.params.slice(1),
444
- }));
445
-
446
- // The rows parameter is stripped just above, and nothing used to check its SHAPE.
447
- // A computed field receives the PAGE and answers one value per row, so a method
448
- // written `items(order: Order)` compiles and dies at the first call with a 500
449
- // (`expected 1 value(s) for 1 row(s), got 0`) — while the information sat here, at
450
- // the scan. Every own method of a presenter IS a computed field
451
- // (`getPresenterFields`), so there is no private helper to spare.
452
- //
453
- // An ERROR at boot, not a refusal: a `blocking` scan diagnostic is logged and the
454
- // app still starts (`bootstrap.ts`, and the comment above that loop says why). Making
455
- // it refuse means deciding that a declaration which cannot work is an unresolved
456
- // CONTRACT — the one thing that does stop the boot — and that is a decision, not a
457
- // severity.
458
- for (const method of parsed) {
459
- const rows = method.params[0];
460
- if (rows && rows.type.array === true) continue;
461
- record({
462
- severity: 'blocking',
463
- code: 'presenter-field-not-page',
464
- filePath,
465
- subject: `${(ctor as { name?: string }).name ?? 'Presenter'}.${method.name}`,
466
- message: `${method.name}(${rows ? `${rows.name}: ${rows.type.raw}` : ''}) is a computed `
467
- + `field, so it receives the PAGE and must answer one value per row. Declare `
468
- + `${method.name}(${rows?.name ?? 'rows'}: ${(target as { name?: string }).name ?? 'Entity'}[]) `
469
- + `and return an array of the same length.`,
470
- });
471
- }
472
- } catch { /* parse failure — fall back to untyped */ }
473
-
474
- // Declared at runtime on the class, so it survives a scan that resolved nothing.
475
- return { entityName, ctor, fields, fieldMeta, views: viewsOf(ctor), deps, filePath };
476
- }
477
-
478
- async function toCollectorEntry(filePath: string): Promise<CollectorEntry | null> {
479
- const ctor = await loadClass(filePath);
480
- const target = targetOf(ctor);
481
- if (!target) return null;
482
- // The target's NAME and nothing else — a collector reads no fields, so the class it
483
- // was built on needs no schema.
484
- const typeName = lowerFirst((target as any).name);
485
- const collectorParams = await ctorParamsOf(filePath);
486
- const deps = collectorParams.map((p) => depKeyOf(p.type));
487
- return { typeName, ctor, deps, filePath };
488
- }
489
-
490
- async function scanFrond(frondPath: string, name: string, source: FrondDescriptor['source'], conventions: Conventions, projectRoot?: string): Promise<FrondDescriptor> {
491
- const {
492
- entities: entitiesDir, handlers: handlersDir,
493
- presenters: presentersDir, collectors: collectorsDir, seeds: seedsDir,
494
- } = conventions.dirs;
495
- /** A convention directory, read by whoever knows the shape it holds. */
496
- const collect = async <T extends object>(
497
- dir: string,
498
- read: (filePath: string) => Promise<T | null>,
499
- ): Promise<T[]> => {
500
- const entries: (T | null)[] = await Promise.all((await files(join(frondPath, dir))).map(read));
501
- return entries.filter((entry): entry is T => entry !== null);
502
- };
503
-
504
- // services/ and repositories/ — two spellings, one provider list.
505
- const providers = (await Promise.all(providerDirsOf(conventions).map((dir) => collect(dir, toProvider)))).flat();
506
- const entities = await collect(entitiesDir, toEntityEntry);
507
- const collectors = await collect(collectorsDir, toCollectorEntry);
508
- const collectorTypeNames = new Set(collectors.map((collector) => collector.typeName));
509
- const frondConfig = await loadFrondConfig(frondPath);
510
- const explicitInputs = new Set(
511
- Object.entries(frondConfig?.operations ?? {})
512
- .filter(([, contract]) => contract.input !== undefined)
513
- .map(([operation]) => operation),
514
- );
515
-
516
- // Handlers resolve `T` against the entities, so those come first.
517
- const entityByClassName = new Map(
518
- entities.map((e) => [(e.entityClass as { name: string }).name, e.entityClass]),
519
- );
520
- const handlers = await collect(handlersDir, (f) =>
521
- toHandlerEntry(f, entityByClassName, collectorTypeNames, explicitInputs, projectRoot));
522
-
523
- // A subdirectory of handlers/ is a named surface — the one directory whose CHILDREN
524
- // are part of the convention too.
525
- for (const surface of await dirs(join(frondPath, handlersDir))) {
526
- handlers.push(...await collect(join(handlersDir, surface), (f) =>
527
- toHandlerEntry(f, entityByClassName, collectorTypeNames, explicitInputs, projectRoot, surface)));
528
- }
529
-
530
- const presenters = await collect(presentersDir, toPresenterEntry);
531
- const seeds = await collect(seedsDir, toSeedEntry);
532
-
533
- // Mark exposed entries: frond.config.ts takes precedence, then @expose decorator
534
- if (frondConfig?.expose) {
535
- const exposeSet = new Set(frondConfig.expose);
536
- for (const e of entities) {
537
- e.exposed = exposeSet.has((e.entityClass as any).name);
538
- }
539
- for (const h of handlers) {
540
- h.exposed = exposeSet.has(h.ctor.name);
541
- }
542
- } else {
543
- // Fallback: check @expose decorator, default to true (expose everything unless explicitly hidden)
544
- for (const e of entities) {
545
- e.exposed = (e.entityClass as any).__exposed !== false;
546
- }
547
- for (const h of handlers) {
548
- h.exposed = (h.ctor as any).__exposed !== false;
549
- }
550
- }
551
-
552
- // Flatten per-op config overrides: the only thing that needs flattening is the handler
553
- // CLASS, which becomes its name (that is the DI key). Everything else — the surface keys
554
- // AND the contract keys (`input`, `binding`) — travels verbatim, so a slot added to
555
- // OperationOverride reaches its reader without a stop here. Enumerating keys by hand is
556
- // what used to silently drop whatever was added last (the same invariant `cloneField`
557
- // holds one layer down).
558
- const operationsOverrides = frondConfig?.operations
559
- ? Object.fromEntries(
560
- Object.entries(frondConfig.operations).map(([opName, { handler, ...rest }]) => [
561
- opName,
562
- { ...rest, handlerName: handler?.name },
563
- ]),
564
- )
565
- : undefined;
566
-
567
- return {
568
- name,
569
- source,
570
- providers,
571
- entities,
572
- handlers,
573
- presenters,
574
- collectors,
575
- seeds,
576
- surfaces: frondConfig?.surfaces,
577
- reads: frondConfig?.reads,
578
- operationsOverrides,
579
- };
580
- }
581
-
582
- /** The frond's name — the directory, unless its `package.json` renames it. */
583
- async function frondNameOf(frondPath: string, dirName: string): Promise<string> {
584
- try {
585
- const pkg = JSON.parse(await readFile(join(frondPath, 'package.json'), 'utf8')) as {
586
- fougere?: { frond?: unknown };
587
- };
588
- const declared = pkg.fougere?.frond;
589
- return typeof declared === 'string' && declared.length > 0 ? declared : dirName;
590
- } catch {
591
- return dirName;
592
- }
593
- }
594
-
595
- /**
596
- * A frond is a directory carrying the convention, and the project root is one such directory — so
597
- * a single-domain app writes `entities/` next to `app/` and never names anything.
598
- */
599
- async function rootFrondOf(root: string, workspaceRoot: string, conventions: Conventions): Promise<FrondDescriptor | null> {
600
- if ((await files(join(root, conventions.dirs.entities))).length === 0) return null;
601
- const name = await frondNameOf(root, basename(resolvePath(root)));
602
- return scanFrond(root, name, { path: root, package: frondPackage(name, conventions) }, conventions, workspaceRoot);
603
- }
604
-
605
- /** `@fronds/<name>` → the directory it names, for every frond of a project. */
606
- export async function frondAliases(root: string, conventions: Conventions = DEFAULT_CONVENTIONS): Promise<Record<string, string>> {
607
- const frondsDir = join(root, conventions.fronds);
608
- const aliases: Record<string, string> = {};
609
-
610
- // The root itself is a frond when it carries `entities/` — same rule as the scan.
611
- if ((await files(join(root, conventions.dirs.entities))).length > 0) {
612
- aliases[frondPackage(await frondNameOf(root, basename(resolvePath(root))), conventions)] = resolvePath(root);
613
- }
614
- for (const dir of await dirs(frondsDir)) {
615
- const path = join(frondsDir, dir);
616
- aliases[frondPackage(await frondNameOf(path, dir), conventions)] = resolvePath(path);
617
- }
618
-
619
- /** A SYNCED frond answers to the same name. */
620
- try {
621
- const registry = JSON.parse(
622
- await readFile(join(root, '.fougere', 'remotes.json'), 'utf8'),
623
- ) as Record<string, { path?: string }>;
624
- for (const [name, entry] of Object.entries(registry)) {
625
- if (typeof entry?.path !== 'string' || aliases[frondPackage(name, conventions)]) continue;
626
- aliases[frondPackage(name, conventions)] = resolvePath(entry.path);
627
- }
628
- } catch {
629
- // No registry, or an unreadable one: nothing was synced here. Not this function's
630
- // complaint — `sync` owns that file and reports on it.
631
- }
632
-
633
- return aliases;
634
- }
635
-
636
- export async function scanProject(
637
- root: string,
638
- filter?: string[],
639
- conventionsInput?: ConventionsInput,
640
- ): Promise<ScanResult> {
641
- const conventions = resolveConventions(conventionsInput);
642
- resetTypePrograms();
643
- // A run owns its findings: two scans in one process (a test suite, a watcher)
644
- // must not inherit each other's.
645
- diagnostics = [];
646
-
647
- const frondsDir = join(root, conventions.fronds);
648
- const dirNames = await dirs(frondsDir);
649
- // Resolve workspace root (parent of packages/) for package import resolution
650
- // For monorepo: root is the project dir (e.g. demos/nuxt-blog), workspace root is the repo root
651
- const workspaceRoot = findWorkspaceRoot(root);
652
-
653
- // One program for this run. Seeded here because the parser rebuilds on every root it
654
- // has not seen, and a frond lives outside its project's tsconfig `include`. Both keys
655
- // are seeded: heritage reads under the workspace root, a constructor under none.
656
- const declarations = (await Promise.all(
657
- [root, ...dirNames.map((dir) => join(frondsDir, dir))].flatMap((frondPath) =>
658
- frondDirsOf(conventions).map((dir) => files(join(frondPath, dir)))),
659
- )).flat();
660
- await seedTypeProgram(declarations, workspaceRoot);
661
- await seedTypeProgram(declarations);
662
-
663
- const [rootFrond, under] = await Promise.all([
664
- rootFrondOf(root, workspaceRoot, conventions),
665
- Promise.all(
666
- dirNames.map(async (dir) => {
667
- const name = await frondNameOf(join(frondsDir, dir), dir);
668
- return scanFrond(
669
- join(frondsDir, dir), name,
670
- { path: join(frondsDir, dir), package: frondPackage(name, conventions) },
671
- conventions,
672
- workspaceRoot,
673
- );
674
- }),
675
- ),
676
- ]);
677
-
678
- // The app's own domain first, then the ones it took in.
679
- const all = rootFrond ? [rootFrond, ...under] : under;
680
- const fronds = Fronds.hosting(filter ? all.filter((f) => filter.includes(f.name)) : all);
681
-
682
- return { fronds, diagnostics };
683
- }
684
-
685
- /** What changes when a frond's domain changes — the paths a dev loop watches. */
686
- export function watchPathsOf(
687
- frond: { source: { path: string } },
688
- scanRoot: string,
689
- conventions: Conventions,
690
- ): string[] {
691
- return frond.source.path === scanRoot
692
- ? frondDirsOf(conventions).map((dir) => join(scanRoot, dir))
693
- : [frond.source.path];
694
- }