@fougere/cli 0.9.2-alpha.0 → 0.9.3-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/app/commands/ExplainCommand.ts +14 -0
- package/app/commands/GraphCommand.ts +25 -1
- package/app/commands/LoadCommand.ts +44 -0
- package/fronds/analysis/entities/Load.ts +8 -0
- package/fronds/analysis/handlers/DevtoolsHandler.ts +12 -2
- package/fronds/analysis/handlers/ExplainHandler.ts +6 -0
- package/fronds/analysis/handlers/GraphHandler.ts +6 -3
- package/fronds/analysis/handlers/LoadHandler.ts +43 -0
- package/package.json +9 -8
|
@@ -88,12 +88,26 @@ export function renderExplain(result: ExplainResult): string {
|
|
|
88
88
|
`${pc.dim('Surfaces:')} ${result.exposure.surfaces.join(', ') || '—'}`,
|
|
89
89
|
`${pc.dim('Adapters:')} ${result.exposure.adapters.join(', ') || '—'}`,
|
|
90
90
|
`${pc.dim('Placement:')} ${result.placement.frond} / ${result.placement.runtime}${result.placement.remote ? ` (${result.placement.remote})` : ''}`,
|
|
91
|
+
// Where the work GOES, under where it answers. The hop count is what a sampling rate, a
|
|
92
|
+
// histogram's bounds and a load threshold are all a function of — each hard-coded today.
|
|
93
|
+
`${pc.dim('Reach:')} ${renderReach(result.reach)}`,
|
|
91
94
|
);
|
|
92
95
|
|
|
93
96
|
if (result.handler.file) lines.push(`${pc.dim('Source:')} ${result.handler.file}`);
|
|
94
97
|
return lines.join('\n');
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
/** What this op reaches, and the part of it that costs a round trip. */
|
|
101
|
+
function renderReach(reach: ExplainResult['reach']): string {
|
|
102
|
+
if (reach.fronds.length === 0) return pc.dim('nothing outside its own frond');
|
|
103
|
+
|
|
104
|
+
const named = reach.fronds
|
|
105
|
+
.map((one) => (one.runtime === 'remote' ? pc.yellow(one.frond) : pc.cyan(one.frond)))
|
|
106
|
+
.join(', ');
|
|
107
|
+
|
|
108
|
+
return `${named} ${pc.dim(`— ${reach.hops} hop(s)`)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
97
111
|
export function renderListing(listing: ExplainListing): string {
|
|
98
112
|
const lines = [pc.bold('Fronds')];
|
|
99
113
|
if (listing.fronds.length === 0) lines.push(' —');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GraphResult } from '../../fronds/analysis/handlers/GraphHandler.js';
|
|
2
|
-
import type { EntityNode, DomainCluster, App } from '@fougere/core';
|
|
2
|
+
import type { DeclaredTopology, EntityNode, DomainCluster, App } from '@fougere/core';
|
|
3
3
|
import { createAppRunner } from '@fougere/core';
|
|
4
4
|
import type { ui as createUi } from '../../src/ui.js';
|
|
5
5
|
import pc from 'picocolors';
|
|
@@ -25,6 +25,7 @@ export default class GraphCommand {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
this.ui.step(`${pc.bold(String(result.totalEntities))} entities, ${pc.bold(String(result.totalRefs))} refs, ${pc.bold(String(result.fronds.length))} frond(s)`);
|
|
28
|
+
this.ui.note(renderFronds(result.declared), 'Fronds');
|
|
28
29
|
this.ui.note(renderGraph(result.nodes), 'Entity Graph');
|
|
29
30
|
|
|
30
31
|
const min = (raw.minEntities as number) ?? 6;
|
|
@@ -39,6 +40,29 @@ export default class GraphCommand {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Where each frond runs, and which reaches which. Read from `remotes:` and from what handlers
|
|
45
|
+
* ask for — never from a call, so this says the same thing with nothing running.
|
|
46
|
+
*/
|
|
47
|
+
function renderFronds(declared: DeclaredTopology): string {
|
|
48
|
+
const reached = new Map<string, string[]>();
|
|
49
|
+
for (const edge of declared.edges) {
|
|
50
|
+
reached.set(edge.from, [...(reached.get(edge.from) ?? []), edge.to]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const width = Math.max(0, ...declared.fronds.map((one) => one.frond.length));
|
|
54
|
+
|
|
55
|
+
return declared.fronds.map((one) => {
|
|
56
|
+
const name = pc.bold(one.frond.padEnd(width));
|
|
57
|
+
const where = one.placement === 'local' ? pc.dim('local ') : pc.yellow('remote');
|
|
58
|
+
const at = one.at ? ` ${pc.dim(one.at)}` : '';
|
|
59
|
+
const calls = reached.get(one.frond);
|
|
60
|
+
const out = calls ? ` ${pc.dim('→')} ${calls.map((to) => pc.cyan(to)).join(', ')}` : '';
|
|
61
|
+
|
|
62
|
+
return ` ${name} ${where}${at}${out}`;
|
|
63
|
+
}).join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
function renderGraph(nodes: Map<string, EntityNode>): string {
|
|
43
67
|
return [...nodes.entries()].map(([name, node]) => {
|
|
44
68
|
const refs = node.refs.length > 0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { App } from '@fougere/core';
|
|
2
|
+
import { createAppRunner } from '@fougere/core';
|
|
3
|
+
import type { LoadScenario } from '../../fronds/analysis/handlers/LoadHandler.js';
|
|
4
|
+
import type { ui as createUi } from '../../src/ui.js';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import { machineWanted, printMachine } from '../../src/machine.js';
|
|
7
|
+
|
|
8
|
+
type Ui = ReturnType<typeof createUi>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The command the generated header has been naming all along.
|
|
12
|
+
*
|
|
13
|
+
* What it writes is derived — the operations from what the project serves, a body for each from
|
|
14
|
+
* the entity's own fields, the envelope from the one function that states it. What it cannot
|
|
15
|
+
* derive is what the load IS: the weights, the stages and the thresholds are facts about your
|
|
16
|
+
* users, and the file says so where they sit.
|
|
17
|
+
*/
|
|
18
|
+
export default class LoadCommand {
|
|
19
|
+
constructor(private app: App, private ui: Ui) {}
|
|
20
|
+
|
|
21
|
+
async run(raw: Record<string, unknown>) {
|
|
22
|
+
const result = await createAppRunner(this.app)(
|
|
23
|
+
{ entity: 'load', op: 'execute' },
|
|
24
|
+
{ params: {}, query: {}, input: raw, state: {} },
|
|
25
|
+
) as LoadScenario;
|
|
26
|
+
|
|
27
|
+
if (machineWanted(raw)) return printMachine(result);
|
|
28
|
+
|
|
29
|
+
if (result.operations.length === 0) {
|
|
30
|
+
this.ui.warn('No operation answers the default door — nothing to put under load.');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!result.file) {
|
|
35
|
+
process.stdout.write(result.script);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.ui.step(`${pc.bold(String(result.operations.length))} operation(s) against ${pc.dim(result.door)}`);
|
|
40
|
+
this.ui.note(result.operations.map((one) => ` ${one}`).join('\n'), 'Under load');
|
|
41
|
+
this.ui.info(`${pc.dim('Written to')} ${result.file} ${pc.dim('— edit the weights, the stages and the thresholds.')}`);
|
|
42
|
+
this.ui.info(`${pc.dim('Then:')} k6 run ${result.file}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { entity, bool, text } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
export default class Load extends entity({
|
|
4
|
+
root: text({ description: 'Project root directory (default: cwd)' }),
|
|
5
|
+
door: text({ description: 'Where the calls go (default: http://127.0.0.1:3000/_fougere/call)' }),
|
|
6
|
+
out: text({ description: 'Where to write it, or empty to print it on stdout (default: <root>/load.js)' }),
|
|
7
|
+
json: bool({ default: false, description: 'Print the scenario as JSON, with nothing else on stdout' }),
|
|
8
|
+
}) {}
|
|
@@ -2,9 +2,18 @@ import { createHttpTransport } from '@fougere/transport-http/client';
|
|
|
2
2
|
import type { CallPage, CallRecord } from '@fougere/core';
|
|
3
3
|
import ProjectScan from '../services/ProjectScan.js';
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Where an app answers in development — Nuxt, Next and the site all sit there, so it is a
|
|
7
|
+
* convention and not a setting. A port belongs to the HOST, though, and this project states
|
|
8
|
+
* none: an app served anywhere else is named the way `FOUGERE_LOG_LEVEL` names a level the
|
|
9
|
+
* file did not, and `--url` still wins over both.
|
|
10
|
+
*/
|
|
6
11
|
const LOCAL = 'http://127.0.0.1:3000';
|
|
7
12
|
|
|
13
|
+
function local(): string {
|
|
14
|
+
return trimmed(process.env.FOUGERE_URL || LOCAL);
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/** One address that was asked, and what came back from it. */
|
|
9
18
|
export interface CallSource {
|
|
10
19
|
url: string;
|
|
@@ -70,10 +79,11 @@ export default class DevtoolsHandler {
|
|
|
70
79
|
*/
|
|
71
80
|
private async addresses(root?: string): Promise<{ url: string; frond?: string }[]> {
|
|
72
81
|
const { config } = await this.projectScan.at(root);
|
|
82
|
+
const here = local();
|
|
73
83
|
const remotes = Object.entries(config.remotes ?? {})
|
|
74
84
|
.map(([frond, url]) => ({ url: trimmed(url), frond }));
|
|
75
85
|
|
|
76
|
-
return [{ url:
|
|
86
|
+
return [{ url: here }, ...remotes.filter((one) => one.url !== here)];
|
|
77
87
|
}
|
|
78
88
|
}
|
|
79
89
|
|
|
@@ -59,6 +59,11 @@ export interface ExplainResult {
|
|
|
59
59
|
runtime: 'local' | 'remote';
|
|
60
60
|
remote: string | null;
|
|
61
61
|
};
|
|
62
|
+
/** Where the work GOES, next to where it answers — and how much of it leaves the process. */
|
|
63
|
+
reach: {
|
|
64
|
+
fronds: { frond: string; runtime: 'local' | 'remote' }[];
|
|
65
|
+
hops: number;
|
|
66
|
+
};
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
/** What this project serves, when no single operation was named. */
|
|
@@ -193,6 +198,7 @@ function project(operation: EffectiveOperation, root: string): ExplainResult {
|
|
|
193
198
|
runtime: operation.placement.runtime,
|
|
194
199
|
remote: operation.placement.remote ?? null,
|
|
195
200
|
},
|
|
201
|
+
reach: operation.reach,
|
|
196
202
|
};
|
|
197
203
|
}
|
|
198
204
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
-
buildGraph, suggestSplit,
|
|
3
|
-
type EntityNode, type DomainCluster, type FrondDescriptor,
|
|
2
|
+
buildGraph, declaredTopologyOf, suggestSplit,
|
|
3
|
+
type DeclaredTopology, type EntityNode, type DomainCluster, type FrondDescriptor,
|
|
4
4
|
} from '@fougere/core';
|
|
5
5
|
import ProjectScan from '../services/ProjectScan.js';
|
|
6
6
|
|
|
7
7
|
export interface GraphResult {
|
|
8
8
|
fronds: FrondDescriptor[];
|
|
9
|
+
/** Where the fronds run and which reaches which — the same picture, one altitude up. */
|
|
10
|
+
declared: DeclaredTopology;
|
|
9
11
|
nodes: Map<string, EntityNode>;
|
|
10
12
|
clusters: DomainCluster[];
|
|
11
13
|
totalEntities: number;
|
|
@@ -17,12 +19,13 @@ export default class GraphHandler {
|
|
|
17
19
|
|
|
18
20
|
/** Report how a workspace's fronds and entities reference each other. */
|
|
19
21
|
async execute(input: { root?: string; minEntities?: number }): Promise<GraphResult> {
|
|
20
|
-
const { fronds } = await this.projectScan.at(input.root);
|
|
22
|
+
const { fronds, config } = await this.projectScan.at(input.root);
|
|
21
23
|
const nodes = buildGraph(fronds);
|
|
22
24
|
const clusters = suggestSplit(nodes);
|
|
23
25
|
|
|
24
26
|
return {
|
|
25
27
|
fronds,
|
|
28
|
+
declared: declaredTopologyOf({ fronds, remotes: config.remotes ?? {} }),
|
|
26
29
|
nodes,
|
|
27
30
|
clusters,
|
|
28
31
|
totalEntities: nodes.size,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { loadScript, reachableOps } from '@fougere/testing';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import ProjectScan from '../services/ProjectScan.js';
|
|
5
|
+
|
|
6
|
+
export interface LoadScenario {
|
|
7
|
+
/** Where it was written, or `null` when it was only printed. */
|
|
8
|
+
file: string | null;
|
|
9
|
+
door: string;
|
|
10
|
+
/** Every operation the scenario calls, in the order it lists them. */
|
|
11
|
+
operations: string[];
|
|
12
|
+
script: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A load scenario, written from what the project serves.
|
|
17
|
+
*
|
|
18
|
+
* Read from the SCAN rather than a boot: an app that boots runs its migrations and plants its
|
|
19
|
+
* seeds, and a command that describes a project has no business writing to its database.
|
|
20
|
+
*/
|
|
21
|
+
export default class LoadHandler {
|
|
22
|
+
constructor(private projectScan: ProjectScan) {}
|
|
23
|
+
|
|
24
|
+
/** Generate a k6 scenario covering every operation the default door answers. */
|
|
25
|
+
async execute(input: { root?: string; door?: string; out?: string }): Promise<LoadScenario> {
|
|
26
|
+
const { root, fronds, config } = await this.projectScan.at(input.root);
|
|
27
|
+
const door = input.door?.trim() || undefined;
|
|
28
|
+
// The topology statement travels with it: an op that crosses a process is not held to the
|
|
29
|
+
// same figure as one that never leaves, and `remotes:` is what says which is which.
|
|
30
|
+
const remotes = config.remotes ?? {};
|
|
31
|
+
const script = loadScript({ fronds }, { ...(door ? { door } : {}), remotes });
|
|
32
|
+
|
|
33
|
+
const file = input.out === undefined ? join(root, 'load.js') : input.out || null;
|
|
34
|
+
if (file) await writeFile(file, script, 'utf8');
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
file,
|
|
38
|
+
door: door ?? 'http://127.0.0.1:3000/_fougere/call',
|
|
39
|
+
operations: reachableOps({ fronds }, {}, remotes).map((one) => one.method),
|
|
40
|
+
script,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fougere/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3-alpha.0",
|
|
4
4
|
"description": "The Fougere CLI — compose a workspace, serve a frond, call an operation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fougere",
|
|
@@ -42,13 +42,14 @@
|
|
|
42
42
|
"jiti": "^2.4.2",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
44
|
"safe-regex": "^2.1.1",
|
|
45
|
-
"@fougere/
|
|
46
|
-
"@fougere/
|
|
47
|
-
"@fougere/
|
|
48
|
-
"@fougere/
|
|
49
|
-
"@fougere/
|
|
50
|
-
"@fougere/
|
|
51
|
-
"@fougere/
|
|
45
|
+
"@fougere/transport-http": "0.9.3-alpha.0",
|
|
46
|
+
"@fougere/adapter-sql": "0.9.3-alpha.0",
|
|
47
|
+
"@fougere/testing": "0.9.3-alpha.0",
|
|
48
|
+
"@fougere/defaults": "0.9.3-alpha.0",
|
|
49
|
+
"@fougere/container": "0.9.3-alpha.0",
|
|
50
|
+
"@fougere/schema": "0.9.3-alpha.0",
|
|
51
|
+
"@fougere/compiler": "0.9.3-alpha.0",
|
|
52
|
+
"@fougere/core": "0.9.3-alpha.0"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"vitest": "^4.1.0"
|