@fougere/core 0.7.0-alpha.0 → 0.8.1-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 +25 -2
- 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 +40 -3
- 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/scan/statement.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** The scan, written as the STATEMENT an author would have written. */
|
|
2
2
|
import { relative } from 'node:path';
|
|
3
|
+
import { nameOf } from '../descriptor/frond.js';
|
|
3
4
|
import type { FrondDescriptor } from '../descriptor/frond.js';
|
|
4
5
|
import type { ScanResult } from './result.js';
|
|
5
|
-
|
|
6
|
-
type Live = { name?: string };
|
|
6
|
+
import { type Aliases, type Live, operationsOf } from './contract.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* A file becomes the specifier the PROJECT already uses for it: `@fronds/blog/…`, the import scope
|
|
@@ -17,30 +17,66 @@ function specifierOf(filePath: string, frond: FrondDescriptor): string {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** One alias per file, so a class imported twice is one binding and one identity. */
|
|
20
|
-
class Imports {
|
|
20
|
+
class Imports implements Aliases {
|
|
21
21
|
private readonly byPath = new Map<string, string>();
|
|
22
|
+
private readonly byValue = new Map<Live, string>();
|
|
23
|
+
private readonly byClassName = new Map<string, Live>();
|
|
22
24
|
private readonly lines: string[] = [];
|
|
25
|
+
/** The frond a file belongs to — a named import needs the specifier its default got. */
|
|
26
|
+
private frondOfFile = new Map<string, FrondDescriptor>();
|
|
23
27
|
|
|
24
|
-
default(filePath: string, frond: FrondDescriptor): string {
|
|
28
|
+
default(filePath: string, frond: FrondDescriptor, value?: Live): string {
|
|
29
|
+
this.frondOfFile.set(filePath, frond);
|
|
25
30
|
const known = this.byPath.get(filePath);
|
|
26
|
-
if (known)
|
|
31
|
+
if (known) {
|
|
32
|
+
if (value) this.remember(value, known);
|
|
33
|
+
|
|
34
|
+
return known;
|
|
35
|
+
}
|
|
27
36
|
const alias = `_${this.byPath.size}`;
|
|
28
37
|
this.lines.push(`import ${alias} from '${specifierOf(filePath, frond)}';`);
|
|
29
38
|
this.byPath.set(filePath, alias);
|
|
39
|
+
if (value) this.remember(value, alias);
|
|
40
|
+
|
|
41
|
+
return alias;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A view a handler declares beside itself — `PostPublic` is exported by name. */
|
|
45
|
+
named(value: Live, filePath: string, name: string): string {
|
|
46
|
+
const known = this.byValue.get(value);
|
|
47
|
+
if (known) return known;
|
|
48
|
+
const frond = this.frondOfFile.get(filePath);
|
|
49
|
+
if (!frond) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`A statement cannot be written: ${name} lives in ${filePath}, which no frond imported first.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const alias = `_${this.byPath.size}_${name}`;
|
|
55
|
+
this.lines.push(`import { ${name} as ${alias} } from '${specifierOf(filePath, frond)}';`);
|
|
56
|
+
this.remember(value, alias);
|
|
30
57
|
|
|
31
58
|
return alias;
|
|
32
59
|
}
|
|
33
60
|
|
|
61
|
+
aliasOf(value: Live): string | undefined { return this.byValue.get(value); }
|
|
62
|
+
classNamed(name: string): Live | undefined { return this.byClassName.get(name); }
|
|
63
|
+
|
|
64
|
+
private remember(value: Live, alias: string): void {
|
|
65
|
+
this.byValue.set(value, alias);
|
|
66
|
+
const name = (value as { name?: string }).name;
|
|
67
|
+
if (name) this.byClassName.set(name, value);
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
render(): string {
|
|
35
71
|
return this.lines.join('\n');
|
|
36
72
|
}
|
|
37
73
|
}
|
|
38
74
|
|
|
39
75
|
/** `{ ctor: X, deps: [...] }` when there is something to say, the bare class otherwise. */
|
|
40
|
-
function subject(alias: string, deps: string[], extra
|
|
76
|
+
function subject(alias: string, deps: string[], ...extra: string[]): string {
|
|
41
77
|
const parts = [
|
|
42
78
|
...(deps.length ? [`deps: ${JSON.stringify(deps)}`] : []),
|
|
43
|
-
...(
|
|
79
|
+
...extra.filter(Boolean),
|
|
44
80
|
];
|
|
45
81
|
|
|
46
82
|
return parts.length ? `{ ctor: ${alias}, ${parts.join(', ')} }` : alias;
|
|
@@ -50,12 +86,21 @@ function frondOf(frond: FrondDescriptor, imports: Imports): string {
|
|
|
50
86
|
const list = (items: string[]): string => `[${items.join(', ')}]`;
|
|
51
87
|
const members: string[] = [];
|
|
52
88
|
|
|
89
|
+
// Entities first, and by VALUE: an operation names its input and output as schemas, and
|
|
90
|
+
// the one a `Partial<X>` derives from is found here rather than imported a second time.
|
|
53
91
|
if (frond.entities.length) {
|
|
54
|
-
members.push(`entities: ${list(frond.entities.map((e) =>
|
|
92
|
+
members.push(`entities: ${list(frond.entities.map((e) =>
|
|
93
|
+
imports.default(e.filePath, frond, e.entityClass as Live)))}`);
|
|
55
94
|
}
|
|
56
95
|
if (frond.handlers.length) {
|
|
57
|
-
members.push(`handlers: ${list(frond.handlers.map((h) =>
|
|
58
|
-
|
|
96
|
+
members.push(`handlers: ${list(frond.handlers.map((h) => subject(
|
|
97
|
+
imports.default(h.filePath, frond, h.ctor as Live),
|
|
98
|
+
h.deps,
|
|
99
|
+
h.surface ? `surface: ${JSON.stringify(h.surface)}` : '',
|
|
100
|
+
// What the scan read from source, and what no class carries at runtime. Without it
|
|
101
|
+
// a host that boots from this statement serves a prefab's five ops and nothing else.
|
|
102
|
+
h.operations.size ? `operations: ${operationsOf(h.operations, h.filePath, imports, ' ')}` : '',
|
|
103
|
+
)))}`);
|
|
59
104
|
}
|
|
60
105
|
if (frond.presenters.length) {
|
|
61
106
|
members.push(`presenters: ${list(frond.presenters.map((p) => subject(imports.default(p.filePath, frond), p.deps)))}`);
|
|
@@ -64,13 +109,26 @@ function frondOf(frond: FrondDescriptor, imports: Imports): string {
|
|
|
64
109
|
members.push(`collectors: ${list(frond.collectors.map((c) => subject(imports.default(c.filePath, frond), c.deps)))}`);
|
|
65
110
|
}
|
|
66
111
|
if (frond.providers.length) {
|
|
67
|
-
members.push(`providers: ${list(frond.providers.map((p) => subject(
|
|
112
|
+
members.push(`providers: ${list(frond.providers.map((p) => subject(
|
|
113
|
+
imports.default(p.filePath, frond, p.ctor as Live),
|
|
114
|
+
p.deps,
|
|
115
|
+
// The container key, and a bundler is free to rewrite the class's own name — it did,
|
|
116
|
+
// and a handler asking for `Communes` met a provider registered as `_Communes`.
|
|
117
|
+
`name: ${JSON.stringify(nameOf(p))}`,
|
|
118
|
+
)))}`);
|
|
68
119
|
}
|
|
69
120
|
// A seed is DATA, not a class — the one member a statement cannot derive from an import.
|
|
70
121
|
if (frond.seeds.length) {
|
|
71
122
|
members.push(`seeds: ${JSON.stringify(frond.seeds.map((s) => ({ entityName: s.entityName, data: s.data })))}`);
|
|
72
123
|
}
|
|
73
124
|
if (frond.surfaces) members.push(`surfaces: ${JSON.stringify(frond.surfaces)}`);
|
|
125
|
+
// What `frond.config.ts` states — read BY the scan, so a host that boots from a written
|
|
126
|
+
// statement never sees the file. It is the only answer for the kind of an op whose name
|
|
127
|
+
// leads with no known verb, and the only one for a method inherited from a base class
|
|
128
|
+
// the workspace scan cannot see.
|
|
129
|
+
if (frond.operationsOverrides && Object.keys(frond.operationsOverrides).length) {
|
|
130
|
+
members.push(`operationsOverrides: ${JSON.stringify(frond.operationsOverrides)}`);
|
|
131
|
+
}
|
|
74
132
|
|
|
75
133
|
const scope = (frond.source.package as string | undefined)?.split('/')[0];
|
|
76
134
|
if (scope) members.push(`scope: ${JSON.stringify(scope)}`);
|
package/src/source.ts
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
|
-
import { Registry, type SchemaView } from '@fougere/schema';
|
|
1
|
+
import { Registry, Role, type SchemaView } from '@fougere/schema';
|
|
2
2
|
import type { StorageFactory } from './storage.js';
|
|
3
3
|
|
|
4
|
+
/** A promise the judge at the door cannot keep, because it is about rows it never sees. */
|
|
5
|
+
export type Constraint = 'unique';
|
|
6
|
+
|
|
7
|
+
/** Whether an entity asks for one, anywhere but on its key. */
|
|
8
|
+
export function declares(schema: SchemaView, constraint: Constraint): boolean {
|
|
9
|
+
if (constraint !== 'unique') return false;
|
|
10
|
+
if ((schema.getUnique() ?? []).length > 0) return true;
|
|
11
|
+
|
|
12
|
+
return Object.values(schema.getFields()).some((field) => Role.of(field).isUnique);
|
|
13
|
+
}
|
|
14
|
+
|
|
4
15
|
/** A place rows live, whatever realizes it. */
|
|
5
16
|
export interface Source {
|
|
6
17
|
storageFactory: StorageFactory;
|
|
7
|
-
/**
|
|
8
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Bring the shape of what lives here up to date, and say what it could not bring.
|
|
20
|
+
*
|
|
21
|
+
* A pass is additive by design and leaves what already exists alone — which is a
|
|
22
|
+
* promise worth keeping and a silence worth breaking: what it declined to change is
|
|
23
|
+
* still a difference, and a boot is where a reader can act on it.
|
|
24
|
+
*/
|
|
25
|
+
migrate?(view: SourceView): Promise<void | string>;
|
|
9
26
|
/** Run `fn` as ONE unit of work, with a factory bound to it. */
|
|
10
27
|
transacted?<R>(fn: (factory: StorageFactory) => Promise<R>): Promise<R>;
|
|
28
|
+
/** What it refuses at the rows themselves. Absent leaves the judge alone with it. */
|
|
29
|
+
enforces?: readonly Constraint[];
|
|
11
30
|
close?(): Promise<void>;
|
|
12
31
|
/** What distinguishes it when a query is reported. */
|
|
13
32
|
name?: string;
|
package/src/store.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** The thirteen gestures of {@link Storage}, derived from four. */
|
|
2
2
|
import { applyCreate, applyUpdate, Lifecycle, Role, type SchemaView } from '@fougere/schema';
|
|
3
|
+
import { comparisonOf, comparisonsIn, type Comparison } from './criterion.js';
|
|
3
4
|
import type { Storage, StorageFactory } from './storage.js';
|
|
4
5
|
|
|
5
6
|
/** One instance, as every realization hands it over. */
|
|
@@ -27,10 +28,16 @@ export function storageOver(open: (entity: SchemaView, name: string) => Store):
|
|
|
27
28
|
// silent and only on this storage.
|
|
28
29
|
const keyOf = (value: unknown) => String(value);
|
|
29
30
|
// Same contract as SQL: a criterion may name a SET, and an empty set matches nothing.
|
|
31
|
+
// A comparison is told from a value by the FIELD, never by the criterion's own shape.
|
|
30
32
|
const matches = (values: Record<string, unknown>, criteria: Record<string, unknown>) =>
|
|
31
|
-
Object.entries(criteria).every(([key, value]) =>
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
Object.entries(criteria).every(([key, value]) => {
|
|
34
|
+
const comparison = comparisonOf(fields[key], value);
|
|
35
|
+
if (comparison) return compares(values[key], comparison);
|
|
36
|
+
|
|
37
|
+
return Array.isArray(value)
|
|
38
|
+
? value.some((v) => Object.is(values[key], v))
|
|
39
|
+
: Object.is(values[key], value);
|
|
40
|
+
});
|
|
34
41
|
|
|
35
42
|
// A store holds whole instances and reads them whole, so the scope SQL puts in its
|
|
36
43
|
// SELECT is applied here on the way out. Same set of gestures either way: everything
|
|
@@ -150,3 +157,35 @@ export function storageOver(open: (entity: SchemaView, name: string) => Store):
|
|
|
150
157
|
return scoped();
|
|
151
158
|
};
|
|
152
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The same comparisons SQL compiles, answered in memory. Every one a criterion names has
|
|
163
|
+
* to hold — the rule two criteria already follow.
|
|
164
|
+
*/
|
|
165
|
+
function compares(held: unknown, comparison: Comparison): boolean {
|
|
166
|
+
return comparisonsIn(comparison).every(([name, asked]) => {
|
|
167
|
+
switch (name) {
|
|
168
|
+
case 'gte': return ordered(held, asked, (a, b) => a >= b);
|
|
169
|
+
case 'lte': return ordered(held, asked, (a, b) => a <= b);
|
|
170
|
+
case 'gt': return ordered(held, asked, (a, b) => a > b);
|
|
171
|
+
case 'lt': return ordered(held, asked, (a, b) => a < b);
|
|
172
|
+
case 'ne': return !Object.is(held, asked);
|
|
173
|
+
case 'contains': return String(held ?? '').includes(String(asked));
|
|
174
|
+
case 'notIn': return !(asked as readonly unknown[]).some((one) => Object.is(held, one));
|
|
175
|
+
case 'isNull': return (held === null || held === undefined) === Boolean(asked);
|
|
176
|
+
case 'between': {
|
|
177
|
+
const [low, high] = asked as [unknown, unknown];
|
|
178
|
+
|
|
179
|
+
return ordered(held, low, (a, b) => a >= b) && ordered(held, high, (a, b) => a <= b);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A row that holds nothing compares to nothing — the answer SQL gives for NULL. */
|
|
186
|
+
const ordered = (held: unknown, asked: unknown, holds: (a: number, b: number) => boolean): boolean =>
|
|
187
|
+
held === null || held === undefined ? false : holds(order(held), order(asked));
|
|
188
|
+
|
|
189
|
+
/** What a Date and a number have in common, and a string keeps for itself. */
|
|
190
|
+
const order = (value: unknown): number =>
|
|
191
|
+
value instanceof Date ? value.getTime() : (value as number);
|