@fougere/core 0.7.0-alpha.0 → 0.8.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 (78) hide show
  1. package/dist/boot/AppLifecycle.d.ts +1 -1
  2. package/dist/boot/AppLifecycle.d.ts.map +1 -1
  3. package/dist/boot/AppLifecycle.js +13 -2
  4. package/dist/boot/AppLifecycle.js.map +1 -1
  5. package/dist/boot/apply.d.ts.map +1 -1
  6. package/dist/boot/apply.js +6 -1
  7. package/dist/boot/apply.js.map +1 -1
  8. package/dist/boot/boot.js +1 -1
  9. package/dist/boot/boot.js.map +1 -1
  10. package/dist/boot/bootstrap.d.ts.map +1 -1
  11. package/dist/boot/bootstrap.js +23 -3
  12. package/dist/boot/bootstrap.js.map +1 -1
  13. package/dist/boot/frame.d.ts.map +1 -1
  14. package/dist/boot/frame.js +4 -9
  15. package/dist/boot/frame.js.map +1 -1
  16. package/dist/boot/types.d.ts +3 -0
  17. package/dist/boot/types.d.ts.map +1 -1
  18. package/dist/contract.d.ts +2 -0
  19. package/dist/contract.d.ts.map +1 -1
  20. package/dist/contract.js +3 -0
  21. package/dist/contract.js.map +1 -1
  22. package/dist/criterion.d.ts +37 -0
  23. package/dist/criterion.d.ts.map +1 -0
  24. package/dist/criterion.js +28 -0
  25. package/dist/criterion.js.map +1 -0
  26. package/dist/declare.d.ts +13 -1
  27. package/dist/declare.d.ts.map +1 -1
  28. package/dist/declare.js +19 -1
  29. package/dist/declare.js.map +1 -1
  30. package/dist/descriptor/frond.d.ts +14 -0
  31. package/dist/descriptor/frond.d.ts.map +1 -1
  32. package/dist/descriptor/frond.js +5 -1
  33. package/dist/descriptor/frond.js.map +1 -1
  34. package/dist/dispatch/StorageGuard.d.ts +32 -1
  35. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  36. package/dist/dispatch/StorageGuard.js +90 -2
  37. package/dist/dispatch/StorageGuard.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/scan/contract.d.ts +27 -0
  41. package/dist/scan/contract.d.ts.map +1 -0
  42. package/dist/scan/contract.js +46 -0
  43. package/dist/scan/contract.js.map +1 -0
  44. package/dist/scan/emit.d.ts.map +1 -1
  45. package/dist/scan/emit.js +7 -41
  46. package/dist/scan/emit.js.map +1 -1
  47. package/dist/scan/scanner.d.ts.map +1 -1
  48. package/dist/scan/scanner.js +17 -6
  49. package/dist/scan/scanner.js.map +1 -1
  50. package/dist/scan/statement.d.ts.map +1 -1
  51. package/dist/scan/statement.js +56 -7
  52. package/dist/scan/statement.js.map +1 -1
  53. package/dist/source.d.ts +15 -3
  54. package/dist/source.d.ts.map +1 -1
  55. package/dist/source.js +9 -1
  56. package/dist/source.js.map +1 -1
  57. package/dist/store.d.ts.map +1 -1
  58. package/dist/store.js +36 -3
  59. package/dist/store.js.map +1 -1
  60. package/package.json +3 -3
  61. package/src/boot/AppLifecycle.ts +15 -2
  62. package/src/boot/apply.ts +6 -1
  63. package/src/boot/boot.ts +1 -1
  64. package/src/boot/bootstrap.ts +26 -4
  65. package/src/boot/frame.ts +4 -10
  66. package/src/boot/types.ts +3 -0
  67. package/src/contract.ts +4 -0
  68. package/src/criterion.ts +54 -0
  69. package/src/declare.ts +34 -2
  70. package/src/descriptor/frond.ts +15 -0
  71. package/src/dispatch/StorageGuard.ts +105 -1
  72. package/src/index.ts +1 -1
  73. package/src/scan/contract.ts +80 -0
  74. package/src/scan/emit.ts +8 -43
  75. package/src/scan/scanner.ts +19 -6
  76. package/src/scan/statement.ts +69 -11
  77. package/src/source.ts +22 -3
  78. package/src/store.ts +42 -3
package/src/boot/frame.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * The compensated realization of a frame — what `Together` becomes when its members do not share
3
3
  * an engine.
4
4
  */
5
- import { FieldSet, Role, type SchemaView } from '@fougere/schema';
5
+ import { FieldSet, type SchemaView } from '@fougere/schema';
6
6
  import { dequal } from 'dequal';
7
+ import { declares } from '../source.js';
7
8
  import type { Logger } from '../builtin/logger.js';
8
9
 
9
10
  /** One write that landed, and how to take it back. */
@@ -30,13 +31,6 @@ interface Undoable {
30
31
  const pick = (row: Record<string, unknown>, keys: readonly string[]): Record<string, unknown> =>
31
32
  Object.fromEntries(keys.map((key) => [key, row[key]]));
32
33
 
33
- /** Whether an upsert's conflict can be something other than the primary key. */
34
- function mayConflictElsewhere(schema: SchemaView): boolean {
35
- if ((schema.getUnique() ?? []).length > 0) return true;
36
-
37
- return Object.values(schema.getFields()).some((field) => Role.of(field).isUnique);
38
- }
39
-
40
34
  /** Refused where it is used, naming the group that makes the inverse ambiguous. */
41
35
  function refuseAmbiguousUpsert(entity: string, gesture: string): never {
42
36
  throw new Error(
@@ -133,7 +127,7 @@ export function recording<T extends object>(storage: T, entity: string, schema:
133
127
 
134
128
  if (typeof base.upsert === 'function') {
135
129
  recorded.upsert = async function (input, ...rest) {
136
- if (mayConflictElsewhere(schema)) refuseAmbiguousUpsert(entity, 'upsert');
130
+ if (declares(schema, 'unique')) refuseAmbiguousUpsert(entity, 'upsert');
137
131
  const undo = await undoUpsert.call(this, [input]);
138
132
  const row = await base.upsert!.call(this, input, ...rest);
139
133
  journal.push(undo);
@@ -143,7 +137,7 @@ export function recording<T extends object>(storage: T, entity: string, schema:
143
137
 
144
138
  if (typeof base.upsertAll === 'function') {
145
139
  recorded.upsertAll = async function (inputs, ...rest) {
146
- if (mayConflictElsewhere(schema)) refuseAmbiguousUpsert(entity, 'upsertAll');
140
+ if (declares(schema, 'unique')) refuseAmbiguousUpsert(entity, 'upsertAll');
147
141
  const undo = await undoUpsert.call(this, inputs);
148
142
  const written = await base.upsertAll!.call(this, inputs, ...rest);
149
143
  journal.push(undo);
package/src/boot/types.ts CHANGED
@@ -5,6 +5,7 @@ import type { FrondDescriptor } from '../descriptor/frond.js';
5
5
  import type { ScanResult } from '../scan/result.js';
6
6
  import type { SchemaView } from '@fougere/schema';
7
7
  import type { StorageFactory } from '../storage.js';
8
+ import type { Constraint } from '../source.js';
8
9
  import type { AppMiddleware } from '../wire/middleware.js';
9
10
  import type { RpcAnswer, Transport } from '../wire/call.js';
10
11
  import type { Extension } from './AppLifecycle.js';
@@ -27,6 +28,8 @@ export interface CreateAppOptions {
27
28
  sourceOf?: (entityName: string) => string;
28
29
  transacts?: (source: string) => boolean;
29
30
  transacted?: <R>(source: string, fn: (storageFactory: StorageFactory) => Promise<R>) => Promise<R>;
31
+ /** Whether that source refuses a constraint at the rows — the boot says so when it does not. */
32
+ enforces?: (source: string, constraint: Constraint) => boolean;
30
33
  /** Builds the cross-source reader a frond gets when it declares `reads:`. */
31
34
  sourcesFactory?: (reads: unknown[], frond: string) => Promise<unknown> | unknown;
32
35
  /** What this app is built from — required, because producing it is what reads a disk. */
package/src/contract.ts CHANGED
@@ -7,6 +7,10 @@ import { canonicalInvocation, type InvocationContext } from './wire/Invocation.j
7
7
  import type { FrondCall } from './wire/call.js';
8
8
 
9
9
  export { FougereError, ErrorCode, validationErrorsOf } from './wire/errors.js';
10
+ // What a read may ask about one field. Here rather than on the main entry because an
11
+ // adapter reads it to compile a query, and an adapter carries no boot.
12
+ export { comparisonOf, comparisonsIn, unknownIn, COMPARISONS } from './criterion.js';
13
+ export type { Comparison, ComparisonName } from './criterion.js';
10
14
  // A receiver turns a refusal into what may cross a process boundary, and it is not
11
15
  // core's boot — reaching it through the main entry dragged the scanner into a bundle.
12
16
  export { toPublicError } from './wire/http-error.js';
@@ -0,0 +1,54 @@
1
+ /** What a read may ask about one field, and how a storage tells the two forms apart. */
2
+ import { type Field, Shapes } from '@fougere/schema';
3
+
4
+ /**
5
+ * The comparisons a criterion may name.
6
+ *
7
+ * `eq` and `in` are not here: a bare value already means equality and an array already
8
+ * means membership, and giving them a second spelling would make one criterion sayable
9
+ * two ways. What is added is what those two could not say at all.
10
+ */
11
+ export interface Comparison {
12
+ gte?: unknown;
13
+ lte?: unknown;
14
+ gt?: unknown;
15
+ lt?: unknown;
16
+ ne?: unknown;
17
+ /** Both bounds, inclusive — `between: [1500, 4000]`. */
18
+ between?: [unknown, unknown];
19
+ contains?: string;
20
+ notIn?: readonly unknown[];
21
+ isNull?: boolean;
22
+ }
23
+
24
+ export const COMPARISONS = ['gte', 'lte', 'gt', 'lt', 'ne', 'between', 'contains', 'notIn', 'isNull'] as const;
25
+
26
+ export type ComparisonName = (typeof COMPARISONS)[number];
27
+
28
+ /**
29
+ * Is this criterion a comparison, or a value that happens to be an object?
30
+ *
31
+ * Read off the FIELD, never off the value. `json()` admits any shape, so `{ gte: … }` is
32
+ * a legal thing to store — telling the two apart by looking at the criterion would make a
33
+ * stored object unfilterable the day its keys happened to spell an operator. The shape
34
+ * knows, and it is the only thing that does.
35
+ */
36
+ export function comparisonOf(field: Field | undefined, asked: unknown): Comparison | undefined {
37
+ if (asked === null || typeof asked !== 'object' || Array.isArray(asked)) return undefined;
38
+ // `Shapes.of` answers with `null` already taken out — `optional(json())` is an object.
39
+ if (!field || Shapes.of(field.shape).base?.type === 'object') return undefined;
40
+
41
+ return asked as Comparison;
42
+ }
43
+
44
+ /** The comparisons a criterion names, in the order the caller wrote them. */
45
+ export function comparisonsIn(comparison: Comparison): [ComparisonName, unknown][] {
46
+ return Object.entries(comparison).filter(
47
+ (entry): entry is [ComparisonName, unknown] => (COMPARISONS as readonly string[]).includes(entry[0]),
48
+ );
49
+ }
50
+
51
+ /** What a comparison names that this vocabulary does not — a typo, said as one. */
52
+ export function unknownIn(comparison: Comparison): string[] {
53
+ return Object.keys(comparison).filter((name) => !(COMPARISONS as readonly string[]).includes(name));
54
+ }
package/src/declare.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  } from './descriptor/frond.js';
7
7
  import { DEFAULT_CONVENTIONS } from './scan/conventions.js';
8
8
  import { getPresenterFields } from './prefab/presenter.js';
9
+ import type { OperationContract } from './wire/operation.js';
9
10
 
10
11
  /** A class, as a declaration hands it over: the constructor itself. */
11
12
  type Ctor = new (...args: never[]) => unknown;
@@ -24,10 +25,19 @@ export interface DeclaredHandler extends DeclaredSubject {
24
25
  * route twice.
25
26
  */
26
27
  surface?: string;
28
+ /**
29
+ * What each method takes and answers — read from SOURCE by the scan, and unreachable
30
+ * from a class at runtime. A prefab declares its own (`Crud.__ops`) and needs nothing
31
+ * here; a method someone wrote does, or the route it should serve does not exist.
32
+ */
33
+ operations?: ReadonlyMap<string, OperationContract> | Record<string, OperationContract>;
27
34
  }
28
35
 
29
36
  /** A class on its own, or a class with what it asks for. */
30
- export type Declared = Ctor | DeclaredSubject;
37
+ export type Declared = Ctor | (DeclaredSubject & {
38
+ /** The container key, when the class's own name cannot be trusted to survive a build. */
39
+ name?: string;
40
+ });
31
41
 
32
42
  const ctorOf = (d: Declared): Ctor => (typeof d === 'function' ? d : d.ctor);
33
43
 
@@ -49,6 +59,22 @@ function subjectOf(ctor: Ctor, kind: string): { name: string } {
49
59
  }
50
60
  const depsOf = (d: Declared): string[] => (typeof d === 'function' ? [] : d.deps ?? []);
51
61
 
62
+ /**
63
+ * A Map either way — a statement is read as code, and an object literal is what code
64
+ * looks like there. What a prefab declares of itself is merged BELOW: a method the author
65
+ * wrote over `create` is the one that runs, so its contract is the one that answers.
66
+ */
67
+ function statedOperations(h: Ctor | DeclaredHandler): Map<string, OperationContract> {
68
+ const declared = typeof h === 'function' ? undefined : h.operations;
69
+ const own = (ctorOf(h) as { __ops?: Record<string, OperationContract> }).__ops ?? {};
70
+ const merged = new Map<string, OperationContract>(Object.entries(own));
71
+ for (const [op, contract] of declared instanceof Map ? declared : Object.entries(declared ?? {})) {
72
+ merged.set(op, contract as OperationContract);
73
+ }
74
+
75
+ return merged;
76
+ }
77
+
52
78
  /** `PostHandler` answers at `post` — the same rule the scan applies to a file it found. */
53
79
  function addressOf(className: string): string {
54
80
  const base = className.endsWith('Handler') ? className.slice(0, -7) : className;
@@ -68,6 +94,8 @@ export interface FrondDeclaration {
68
94
  surfaces?: Record<string, string[]>;
69
95
  /** The import scope this frond answers under. Defaults to the conventional one. */
70
96
  scope?: string;
97
+ /** What `frond.config.ts` states — the third producer of an operation contract. */
98
+ operationsOverrides?: FrondDescriptor['operationsOverrides'];
71
99
  }
72
100
 
73
101
  /** State a frond without reading a disk. */
@@ -93,7 +121,7 @@ export function frond(name: string, declared: FrondDeclaration = {}): FrondDescr
93
121
  // A handler about no stored row is ordinary — the address is not a promise that an
94
122
  // entity carries it, which is why this is not looked up.
95
123
  entityName: address,
96
- operations: new Map(),
124
+ operations: statedOperations(h),
97
125
  deps: depsOf(h),
98
126
  filePath: '',
99
127
  exposed: true,
@@ -132,6 +160,9 @@ export function frond(name: string, declared: FrondDeclaration = {}): FrondDescr
132
160
  });
133
161
 
134
162
  const providers: ProviderEntry[] = (declared.providers ?? []).map((p) => ({
163
+ // Written down where a statement says it, read off the class otherwise — the class's
164
+ // own name is what a bundler is free to rewrite.
165
+ ...(typeof p === 'function' || !p.name ? {} : { name: p.name }),
135
166
  ctor: ctorOf(p),
136
167
  deps: depsOf(p),
137
168
  filePath: '',
@@ -146,6 +177,7 @@ export function frond(name: string, declared: FrondDeclaration = {}): FrondDescr
146
177
  return {
147
178
  name,
148
179
  source: { path: '', package: `${scope}/${name}` },
180
+ ...(declared.operationsOverrides ? { operationsOverrides: declared.operationsOverrides } : {}),
149
181
  providers,
150
182
  entities,
151
183
  handlers,
@@ -6,6 +6,15 @@ import type { Fronds } from './Fronds.js';
6
6
 
7
7
  /** A discovered provider — a class under `services/` or `repositories/`, injected by type. */
8
8
  export interface ProviderEntry {
9
+ /**
10
+ * The container key — the class name as the SOURCE spells it.
11
+ *
12
+ * Read at boot off `ctor.name` for a decade of no consequence, until a bundler lowered
13
+ * a `static readonly` field and renamed the declaration doing it: the provider
14
+ * registered as `_Communes` and the handler asking for `Communes` got a container miss.
15
+ * A name a tool may rewrite is written down, for the same reason `deps` is.
16
+ */
17
+ name?: string;
9
18
  /** The class constructor (default export of the file). */
10
19
  ctor: new (...args: never[]) => unknown;
11
20
  /** Constructor dependency type names (from AST scan). */
@@ -14,6 +23,12 @@ export interface ProviderEntry {
14
23
  filePath: string;
15
24
  }
16
25
 
26
+ /**
27
+ * The key a provider answers under. What the source said, and the class's own name only
28
+ * where nobody wrote it down — a statement is read as code, and code can say it.
29
+ */
30
+ export const nameOf = (provider: ProviderEntry): string => provider.name ?? provider.ctor.name;
31
+
17
32
  /** A discovered entity (Entity subclass in entities/ dir). */
18
33
  export interface EntityEntry {
19
34
  /** Registration key: class name lowercased first char (e.g. 'product'). */
@@ -1,4 +1,5 @@
1
1
  import { Boundary, FieldSet, FieldValueValidator, InputRefusal, type Fields } from '@fougere/schema';
2
+ import { COMPARISONS, comparisonOf, unknownIn } from '../criterion.js';
2
3
  import { assertListOptions } from '../storage.js';
3
4
  import { ErrorCode, FougereError } from '../wire/errors.js';
4
5
 
@@ -11,11 +12,23 @@ interface Writer {
11
12
  list?(...args: unknown[]): unknown;
12
13
  }
13
14
 
15
+ /** What a guard says about a filter it let through. The boot owns the voice. */
16
+ export interface GuardReport {
17
+ /** The fields this door hands back, when it hands back fewer than the entity has. */
18
+ view?: Fields;
19
+ /** Said once per field — a filter is not a write, and a warning per call is noise. */
20
+ outOfView?: (message: string) => void;
21
+ }
22
+
14
23
  /** Judges storage writes and list options without narrowing the storage interface. */
15
24
  export class StorageGuard {
25
+ /** What has already been said, so a filter in a loop says it once. */
26
+ private readonly said = new Set<string>();
27
+
16
28
  constructor(
17
29
  private readonly fields: Fields,
18
30
  private readonly entity: string,
31
+ private readonly report: GuardReport = {},
19
32
  ) {}
20
33
 
21
34
  guard<T extends object>(storage: T): T {
@@ -56,7 +69,10 @@ export class StorageGuard {
56
69
  const list = writer.list;
57
70
  if (typeof list === 'function') {
58
71
  guarded.list = async function (...args: unknown[]) {
59
- assertListOptions(args[0] as object | undefined, validation.entity);
72
+ const options = args[0] as { where?: Record<string, unknown> } | undefined;
73
+ assertListOptions(options, validation.entity);
74
+ if (options?.where) args[0] = { ...options, where: validation.criteria(options.where) };
75
+
60
76
  return list.apply(this, args);
61
77
  };
62
78
  }
@@ -75,6 +91,91 @@ export class StorageGuard {
75
91
  return key === undefined ? `row ${index} of this page` : `row ${primary} ${JSON.stringify(key)}`;
76
92
  }
77
93
 
94
+ /**
95
+ * What a read may ask for. The write door judges what LANDS in a row; this one judges
96
+ * what a caller says about one — and it was the single entrance to the port with no
97
+ * judge at all, while `params.filter` from a browser reaches it verbatim.
98
+ *
99
+ * A criterion may name a SET, which is the one thing the write door would refuse: an
100
+ * array is judged member by member, since that is what `IN` binds.
101
+ */
102
+ private criteria(where: Record<string, unknown>): Record<string, unknown> {
103
+ const errors: string[] = [];
104
+ const parsed: Record<string, unknown> = {};
105
+
106
+ for (const [key, asked] of Object.entries(where)) {
107
+ const field = this.fields[key];
108
+ if (!field) {
109
+ errors.push(`${key}: ${InputRefusal.unknownField}`);
110
+ continue;
111
+ }
112
+ // A comparison names its own vocabulary, and a typo in it would otherwise be a
113
+ // criterion that filters nothing — the silent truncation this door exists to stop.
114
+ const comparison = comparisonOf(field, asked);
115
+ if (comparison) {
116
+ const unknown = unknownIn(comparison);
117
+ if (unknown.length) {
118
+ errors.push(`${key}: unknown comparison ${unknown.join(', ')} — one of ${COMPARISONS.join(', ')}`);
119
+ continue;
120
+ }
121
+ parsed[key] = comparison;
122
+ this.beyondTheView(key);
123
+ continue;
124
+ }
125
+
126
+ const values = Array.isArray(asked) ? asked : [asked];
127
+ const each = values.map((value) => this.value(field, value));
128
+ const refused = each.find((one) => typeof one === 'object' && one !== null && 'error' in one);
129
+ if (refused) {
130
+ errors.push(`${key}: ${(refused as { error: string }).error}`);
131
+ continue;
132
+ }
133
+ parsed[key] = Array.isArray(asked) ? each.map(unwrap) : unwrap(each[0]);
134
+ this.beyondTheView(key);
135
+ }
136
+
137
+ if (errors.length > 0) {
138
+ throw new FougereError({
139
+ code: ErrorCode.BAD_REQUEST,
140
+ message: `Refused as a filter — ${errors.join(', ')}`,
141
+ entity: this.entity,
142
+ operation: 'list',
143
+ details: errors,
144
+ });
145
+ }
146
+
147
+ return parsed;
148
+ }
149
+
150
+ /**
151
+ * A filter on a field this door does not hand back.
152
+ *
153
+ * `output(schema)` narrows what is RETURNED and has never narrowed what is asked, so a
154
+ * caller can already sort a hidden column into existence one comparison at a time — and
155
+ * the admin door copies a browser's filter here verbatim. Said rather than refused: it
156
+ * is legal today, GraphQL batches a relation on a key a view may not carry, and a
157
+ * refusal would break that on the way to fixing this.
158
+ */
159
+ private beyondTheView(field: string): void {
160
+ const view = this.report.view;
161
+ if (!view || view[field] || this.said.has(field)) return;
162
+ this.said.add(field);
163
+ this.report.outOfView?.(
164
+ `${this.entity}.list() filtered on '${field}', which this door does not hand back — `
165
+ + 'a filter on a hidden field answers questions about it one call at a time.',
166
+ );
167
+ }
168
+
169
+ /** One value against one field — validated, then decoded the way the wire hands it. */
170
+ private value(field: Fields[string], asked: unknown): { value: unknown } | { error: string } {
171
+ if (asked === null || asked === undefined) return { value: asked };
172
+ const checked = FieldValueValidator.of(field).validate(asked);
173
+ if ('error' in checked) return checked;
174
+ if (checked.value === null) return { value: null };
175
+
176
+ return Boundary.of(field).decode(checked.value);
177
+ }
178
+
78
179
  private validated<T>(value: T, operation: string, index?: number): T {
79
180
  if (typeof value !== 'object' || value === null) return value;
80
181
 
@@ -121,3 +222,6 @@ export class StorageGuard {
121
222
  return parsed as T;
122
223
  }
123
224
  }
225
+
226
+ const unwrap = (one: { value: unknown } | { error: string }): unknown =>
227
+ 'value' in one ? one.value : undefined;
package/src/index.ts CHANGED
@@ -85,7 +85,7 @@ export { applyConfig, type ConfigApplication } from './boot/apply.js';
85
85
  export { Config } from './builtin/config.js';
86
86
  export type { Storage, StorageFactory, ListOptions, ListResult, Together } from './storage.js';
87
87
  export { togetherKeyOf, membersOfTogetherKey } from './storage.js';
88
- export type { Source, SourceConfig, SourceView } from './source.js';
88
+ export type { Constraint, Source, SourceConfig, SourceView } from './source.js';
89
89
  export { Sources } from './source.js';
90
90
  export { storageOver } from './store.js';
91
91
  export type { Store, Values } from './store.js';
@@ -0,0 +1,80 @@
1
+ /** An operation contract, written down — the half two emitters say the same way. */
2
+ import { ANONYMOUS_SCHEMA_NAME, Card, type SchemaView } from '@fougere/schema';
3
+
4
+ import type { OperationContract } from '../wire/operation.js';
5
+
6
+ /** A value that ends up as an import in the module being written. */
7
+ export type Live = object;
8
+
9
+ /**
10
+ * What the writer needs of an emitter's imports, and nothing more.
11
+ *
12
+ * The two emitters index theirs differently — one by value against a relative path, one
13
+ * by file against the package specifier a project already uses — and neither is wrong for
14
+ * the module it writes. What they agree on is this: a value already imported has an
15
+ * alias, a class can be imported by name, and an entity can be found by its class name.
16
+ */
17
+ export interface Aliases {
18
+ aliasOf(value: Live): string | undefined;
19
+ named(value: Live, filePath: string, name: string): string;
20
+ /** The entity class this name belongs to — what a `Partial<X>` names as its source. */
21
+ classNamed(name: string): Live | undefined;
22
+ }
23
+
24
+ export const lit = (value: unknown): string => JSON.stringify(value ?? null);
25
+
26
+ /** What a schema slot becomes in the generated module. */
27
+ export function schemaRef(
28
+ schema: SchemaView | undefined,
29
+ declaredIn: string,
30
+ imports: Aliases,
31
+ ): string | undefined {
32
+ if (!schema) return undefined;
33
+ const known = imports.aliasOf(schema as Live);
34
+ if (known) return known;
35
+
36
+ const name = (schema as { name?: string }).name;
37
+ if (name && name !== ANONYMOUS_SCHEMA_NAME) return imports.named(schema as Live, declaredIn, name);
38
+
39
+ const card = Card.fromSchema(schema);
40
+ const source = card.origin?.from ?? card.descriptor.title;
41
+ const from = source ? imports.classNamed(source) : undefined;
42
+ if (from) return `${imports.aliasOf(from)}.partial()`;
43
+
44
+ throw new Error(
45
+ `A scan cannot be written down: an anonymous schema in ${declaredIn} names no source. `
46
+ + 'Only `Partial<X>` is derivable here, and it says which X it came from.',
47
+ );
48
+ }
49
+
50
+ /** One entry of an operations Map, as its module spells it. */
51
+ export function contractOf(
52
+ op: string,
53
+ contract: OperationContract,
54
+ declaredIn: string,
55
+ imports: Aliases,
56
+ ): string {
57
+ const parts: string[] = [];
58
+ const input = schemaRef(contract.input, declaredIn, imports);
59
+ const output = schemaRef(contract.output, declaredIn, imports);
60
+ if (input) parts.push(`input: ${input}`);
61
+ if (output) parts.push(`output: ${output}`);
62
+ if (contract.binding !== undefined) parts.push(`binding: ${lit(contract.binding)}`);
63
+ if (contract.description !== undefined) parts.push(`description: ${lit(contract.description)}`);
64
+ if (contract.cardinality !== undefined) parts.push(`cardinality: ${lit(contract.cardinality)}`);
65
+ if (contract.signature !== undefined) parts.push(`signature: ${lit(contract.signature)}`);
66
+
67
+ return `[${lit(op)}, { ${parts.join(', ')} }]`;
68
+ }
69
+
70
+ /** The operations of one handler, as the Map its reader builds. */
71
+ export function operationsOf(
72
+ operations: ReadonlyMap<string, OperationContract>,
73
+ declaredIn: string,
74
+ imports: Aliases,
75
+ indent: string,
76
+ ): string {
77
+ const ops = [...operations].map((entry) => contractOf(entry[0], entry[1], declaredIn, imports));
78
+
79
+ return `new Map([\n${indent} ${ops.join(`,\n${indent} `)}\n${indent}])`;
80
+ }
package/src/scan/emit.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /** The scan, written down as a module — what `createApp` is handed where there is no disk. */
2
2
  import { dirname, relative } from 'node:path';
3
- import { ANONYMOUS_SCHEMA_NAME, Card, type SchemaView } from '@fougere/schema';
3
+ import { nameOf } from '../descriptor/frond.js';
4
4
  import type { FrondDescriptor, EntityEntry, HandlerEntry, PresenterEntry, CollectorEntry, ProviderEntry, SeedEntry } from '../descriptor/frond.js';
5
5
  import type { ScanResult } from './result.js';
6
- import type { OperationContract } from '../wire/operation.js';
6
+ import { type Aliases, type Live, lit, operationsOf, schemaRef } from './contract.js';
7
7
 
8
8
  export interface EmitOptions {
9
9
  /** Where the generated module will sit. Imports are written relative to it. */
@@ -12,8 +12,6 @@ export interface EmitOptions {
12
12
  core?: string;
13
13
  }
14
14
 
15
- type Live = object;
16
-
17
15
  /** Is a TypeScript compiler going to read this module? Its name is the only thing that says. */
18
16
  const isTypeScript = (outFile: string): boolean => /\.tsx?$/.test(outFile);
19
17
 
@@ -24,9 +22,7 @@ function specifierOf(filePath: string, outFile: string): string {
24
22
  return rel.startsWith('.') ? rel : `./${rel}`;
25
23
  }
26
24
 
27
- const lit = (v: unknown): string => JSON.stringify(v ?? null);
28
-
29
- class Imports {
25
+ class Imports implements Aliases {
30
26
  private readonly byValue = new Map<Live, string>();
31
27
  private readonly lines: string[] = [];
32
28
  /** Entity classes by their class name — what a `Partial<X>` names as its source. */
@@ -56,55 +52,23 @@ class Imports {
56
52
 
57
53
  has(value: Live): boolean { return this.byValue.has(value); }
58
54
  aliasOf(value: Live): string | undefined { return this.byValue.get(value); }
55
+ classNamed(name: string): Live | undefined { return this.byClassName.get(name); }
59
56
  render(): string { return this.lines.join('\n'); }
60
57
  }
61
58
 
62
- /** What a schema slot becomes in the generated module. */
63
- function schemaRef(schema: SchemaView | undefined, declaredIn: string, imports: Imports): string | undefined {
64
- if (!schema) return undefined;
65
- const known = imports.aliasOf(schema as Live);
66
- if (known) return known;
67
-
68
- const name = (schema as { name?: string }).name;
69
- if (name && name !== ANONYMOUS_SCHEMA_NAME) return imports.named(schema as Live, declaredIn, name);
70
-
71
- const card = Card.fromSchema(schema);
72
- const source = card.origin?.from ?? card.descriptor.title;
73
- const from = source ? imports.byClassName.get(source) : undefined;
74
- if (from) return `${imports.aliasOf(from)}.partial()`;
75
-
76
- throw new Error(
77
- `A scan cannot be written down: an anonymous schema in ${declaredIn} names no source. `
78
- + 'Only `Partial<X>` is derivable here, and it says which X it came from.',
79
- );
80
- }
81
-
82
- function contractOf(op: string, c: OperationContract, declaredIn: string, imports: Imports): string {
83
- const parts: string[] = [];
84
- const input = schemaRef(c.input, declaredIn, imports);
85
- const output = schemaRef(c.output, declaredIn, imports);
86
- if (input) parts.push(`input: ${input}`);
87
- if (output) parts.push(`output: ${output}`);
88
- if (c.binding !== undefined) parts.push(`binding: ${lit(c.binding)}`);
89
- if (c.description !== undefined) parts.push(`description: ${lit(c.description)}`);
90
- if (c.cardinality !== undefined) parts.push(`cardinality: ${lit(c.cardinality)}`);
91
- if (c.signature !== undefined) parts.push(`signature: ${lit(c.signature)}`);
92
- return `[${lit(op)}, { ${parts.join(', ')} }]`;
93
- }
94
-
95
59
  function entityOf(e: EntityEntry, imports: Imports): string {
96
60
  return `{ name: ${lit(e.name)}, entityClass: ${imports.aliasOf(e.entityClass as Live)}, `
97
61
  + `filePath: ${lit(e.filePath)}, exposed: ${lit(e.exposed)} }`;
98
62
  }
99
63
 
100
64
  function handlerOf(h: HandlerEntry, imports: Imports): string {
101
- const ops = [...h.operations].map(([op, c]) => contractOf(op, c, h.filePath, imports));
65
+ const ops = operationsOf(h.operations, h.filePath, imports, ' ');
102
66
  const override = schemaRef(h.outputOverride, h.filePath, imports);
103
67
  return `{ name: ${lit(h.name)}, address: ${lit(h.address)}, ctor: ${imports.aliasOf(h.ctor as Live)}, `
104
68
  + `deps: ${lit(h.deps)}, filePath: ${lit(h.filePath)}, exposed: ${lit(h.exposed)}, `
105
69
  + (h.surface ? `surface: ${lit(h.surface)}, ` : '')
106
70
  + (override ? `outputOverride: ${override}, ` : '')
107
- + `operations: new Map([\n ${ops.join(',\n ')}\n ]) }`;
71
+ + `operations: ${ops} }`;
108
72
  }
109
73
 
110
74
  function presenterOf(p: PresenterEntry, imports: Imports): string {
@@ -121,7 +85,8 @@ function collectorOf(c: CollectorEntry, imports: Imports): string {
121
85
  }
122
86
 
123
87
  function providerOf(p: ProviderEntry, imports: Imports): string {
124
- return `{ ctor: ${imports.aliasOf(p.ctor as Live)}, deps: ${lit(p.deps)}, filePath: ${lit(p.filePath)} }`;
88
+ return `{ name: ${lit(nameOf(p))}, ctor: ${imports.aliasOf(p.ctor as Live)}, `
89
+ + `deps: ${lit(p.deps)}, filePath: ${lit(p.filePath)} }`;
125
90
  }
126
91
 
127
92
  function seedOf(s: SeedEntry, imports: Imports): string {
@@ -86,8 +86,18 @@ async function loadDefault(filePath: string): Promise<unknown> {
86
86
 
87
87
  async function loadClass(filePath: string): Promise<ProviderEntry['ctor']> {
88
88
  const ctor = await loadDefault(filePath);
89
- if (typeof ctor !== 'function' || !ctor.prototype)
90
- throw new Error(`${filePath}: default export is not a class`);
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
+ }
91
101
  return ctor as ProviderEntry['ctor'];
92
102
  }
93
103
 
@@ -172,6 +182,9 @@ const handlerMethodsOf = (filePath: string, projectRoot?: string) =>
172
182
 
173
183
  async function toProvider(filePath: string): Promise<ProviderEntry> {
174
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;
175
188
  const params = await ctorParamsOf(filePath);
176
189
  const deps = params.map((p) => depKeyOf(p.type));
177
190
 
@@ -191,10 +204,10 @@ async function toProvider(filePath: string): Promise<ProviderEntry> {
191
204
  deps.push(storageKeyOf(lowerFirst((target as { name: string }).name)));
192
205
  }
193
206
 
194
- // No `name` beside `ctor`: a provider registers under `ctor.name`, which is what
195
- // `depKeyOf` returns since it reads the type as written. The camelCase field that
196
- // used to sit here called itself the registration key and was one nowhere.
197
- return { ctor, deps, filePath };
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 };
198
211
  }
199
212
 
200
213
  async function toEntityEntry(filePath: string): Promise<EntityEntry | null> {