@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
@@ -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) return 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 = ''): string {
76
+ function subject(alias: string, deps: string[], ...extra: string[]): string {
41
77
  const parts = [
42
78
  ...(deps.length ? [`deps: ${JSON.stringify(deps)}`] : []),
43
- ...(extra ? [extra] : []),
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) => imports.default(e.filePath, frond)))}`);
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
- subject(imports.default(h.filePath, frond), h.deps, h.surface ? `surface: ${JSON.stringify(h.surface)}` : '')))}`);
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(imports.default(p.filePath, frond), p.deps)))}`);
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
- /** Bring the shape of what lives here up to date. */
8
- migrate?(view: SourceView): Promise<void>;
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]) => Array.isArray(value)
32
- ? value.some((v) => Object.is(values[key], v))
33
- : Object.is(values[key], value));
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);