@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.
- package/dist/boot/AppLifecycle.d.ts +1 -1
- package/dist/boot/AppLifecycle.d.ts.map +1 -1
- package/dist/boot/AppLifecycle.js +13 -2
- package/dist/boot/AppLifecycle.js.map +1 -1
- package/dist/boot/apply.d.ts.map +1 -1
- package/dist/boot/apply.js +6 -1
- package/dist/boot/apply.js.map +1 -1
- package/dist/boot/boot.js +1 -1
- package/dist/boot/boot.js.map +1 -1
- package/dist/boot/bootstrap.d.ts.map +1 -1
- package/dist/boot/bootstrap.js +23 -3
- package/dist/boot/bootstrap.js.map +1 -1
- package/dist/boot/frame.d.ts.map +1 -1
- package/dist/boot/frame.js +4 -9
- package/dist/boot/frame.js.map +1 -1
- package/dist/boot/types.d.ts +3 -0
- package/dist/boot/types.d.ts.map +1 -1
- package/dist/contract.d.ts +2 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +3 -0
- package/dist/contract.js.map +1 -1
- package/dist/criterion.d.ts +37 -0
- package/dist/criterion.d.ts.map +1 -0
- package/dist/criterion.js +28 -0
- package/dist/criterion.js.map +1 -0
- package/dist/declare.d.ts +13 -1
- package/dist/declare.d.ts.map +1 -1
- package/dist/declare.js +19 -1
- package/dist/declare.js.map +1 -1
- package/dist/descriptor/frond.d.ts +14 -0
- package/dist/descriptor/frond.d.ts.map +1 -1
- package/dist/descriptor/frond.js +5 -1
- package/dist/descriptor/frond.js.map +1 -1
- package/dist/dispatch/StorageGuard.d.ts +32 -1
- package/dist/dispatch/StorageGuard.d.ts.map +1 -1
- package/dist/dispatch/StorageGuard.js +90 -2
- package/dist/dispatch/StorageGuard.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/scan/contract.d.ts +27 -0
- package/dist/scan/contract.d.ts.map +1 -0
- package/dist/scan/contract.js +46 -0
- package/dist/scan/contract.js.map +1 -0
- package/dist/scan/emit.d.ts.map +1 -1
- package/dist/scan/emit.js +7 -41
- package/dist/scan/emit.js.map +1 -1
- package/dist/scan/scanner.d.ts.map +1 -1
- package/dist/scan/scanner.js +17 -6
- package/dist/scan/scanner.js.map +1 -1
- package/dist/scan/statement.d.ts.map +1 -1
- package/dist/scan/statement.js +56 -7
- package/dist/scan/statement.js.map +1 -1
- package/dist/source.d.ts +15 -3
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +9 -1
- package/dist/source.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +36 -3
- package/dist/store.js.map +1 -1
- package/package.json +3 -3
- package/src/boot/AppLifecycle.ts +15 -2
- package/src/boot/apply.ts +6 -1
- package/src/boot/boot.ts +1 -1
- package/src/boot/bootstrap.ts +26 -4
- package/src/boot/frame.ts +4 -10
- package/src/boot/types.ts +3 -0
- package/src/contract.ts +4 -0
- package/src/criterion.ts +54 -0
- package/src/declare.ts +34 -2
- package/src/descriptor/frond.ts +15 -0
- package/src/dispatch/StorageGuard.ts +105 -1
- package/src/index.ts +1 -1
- package/src/scan/contract.ts +80 -0
- package/src/scan/emit.ts +8 -43
- package/src/scan/scanner.ts +19 -6
- package/src/scan/statement.ts +69 -11
- package/src/source.ts +22 -3
- 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,
|
|
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 (
|
|
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 (
|
|
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';
|
package/src/criterion.ts
ADDED
|
@@ -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:
|
|
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,
|
package/src/descriptor/frond.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 `{
|
|
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 {
|
package/src/scan/scanner.ts
CHANGED
|
@@ -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
|
-
|
|
90
|
-
|
|
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
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
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> {
|