Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions bin/utils/config/parser.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import z from 'zod';

import { CONFIG_FILE_STRATEGY, type ResolvedActorConfig, type StrategyParser } from './structures/base.js';
import { GLOBS_PARSER } from './structures/globs.js';
import { GROUPED_PARSER } from './structures/grouped.js';
import { LEGACY_PARSER } from './structures/legacy.js';

const CONFIG_FILE_STRATEGIES: {
// instead of Record, we do this to ensure that the modes match
[key in CONFIG_FILE_STRATEGY]: StrategyParser & { mode: key };
} = {
[CONFIG_FILE_STRATEGY.LEGACY]: LEGACY_PARSER,
[CONFIG_FILE_STRATEGY.GROUPED]: GROUPED_PARSER,
[CONFIG_FILE_STRATEGY.GLOBS]: GLOBS_PARSER,
} as const;

const ModeSelectionSchema = z.enum(CONFIG_FILE_STRATEGY).default(CONFIG_FILE_STRATEGY.LEGACY);
const ModeSelectionSchema = z.object({ mode: z.enum(CONFIG_FILE_STRATEGY).default(CONFIG_FILE_STRATEGY.LEGACY) });

function selectStrategy(mode: unknown): StrategyParser {
const parsed = ModeSelectionSchema.safeParse(mode);
if (!parsed.success) {
throw new Error(z.prettifyError(parsed.error));
}

return CONFIG_FILE_STRATEGIES[parsed.data];
return CONFIG_FILE_STRATEGIES[parsed.data.mode];
}

// Strips a trailing slash so config-declared paths ("actors/shopify/" vs "actors/shopify") compare equal.
Expand Down Expand Up @@ -79,10 +83,9 @@ export const _privates = {
};

export function parseConfigFile(body: Record<string, unknown>): ResolvedActorConfig[] {
const { mode, ...rest } = body;
const strategy = selectStrategy(mode);
const strategy = selectStrategy(body);

const resolved = strategy.parse(rest);
const resolved = strategy.parse(body);
const validated = verifyConfiguration(resolved);
return validated;
}
2 changes: 2 additions & 0 deletions bin/utils/config/structures/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface ResolvedActorConfig {

export enum CONFIG_FILE_STRATEGY {
LEGACY = 'legacy',
GROUPED = 'grouped',
GLOBS = 'globs',
}

export type StrategyParser = {
Expand Down
97 changes: 97 additions & 0 deletions bin/utils/config/structures/globs.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tracking useless globs (they are not owner for no key) might be beneficial to steer people out of dumb configurations

Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { minimatch } from 'minimatch';
import z from 'zod';

import { ACTOR_FULL_NAME_REGEX, CONFIG_FILE_STRATEGY, defineStrategy, type ResolvedActorConfig } from './base.js';

const SETTING_KEYS = ['tokenEnvVar', 'overrideActorContext'] as const;

const schema = z.object({
actors: z
.array(
z.object({
folder: z.string(),
actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX),
tokenEnvVar: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

THEY CAN HAVE STUFF HERE?
whats the precedence?

Also, nice for legacy compat, but slightly worrying

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no precedence, if the configs try to set something that it is already there, it errors. It's why there is the owner that check who set the key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, saw it in the implementation. I think its good.

overrideActorContext: z.array(z.string()).optional(),
}),
)
.min(1),
configs: z
.array(
z.object({
match: z
.object({
folderGlob: z.string().optional(),
actorFullNameGlob: z.string().optional(),
})
.refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one glob is required.'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i hate that this is the zod standard for this kind of thing.


set: z
.object({
tokenEnvVar: z.string().optional(),
overrideActorContext: z.array(z.string()).optional(),
})
.refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one setting is required.'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should do a non-empty object refining function as a zod utility.
This call is strike 3 on the duplication side (i did it once and you have 2 more)

}),
)
.min(1),
});

type GlobsConfig = z.infer<typeof schema>;
type Actor = GlobsConfig['actors'][number];
type Match = GlobsConfig['configs'][number]['match'];

// folders are matched as written (no normalization) and dotfolders are skipped
const matches = (actor: Actor, { folderGlob, actorFullNameGlob }: Match) =>
(folderGlob === undefined || minimatch(actor.folder, folderGlob)) &&
(actorFullNameGlob === undefined || minimatch(actor.actorFullName, actorFullNameGlob));

export const GLOBS_PARSER = defineStrategy(CONFIG_FILE_STRATEGY.GLOBS, schema, (body: GlobsConfig) => {
const errors: string[] = [];

const resolved = body.actors.flatMap((actor): ResolvedActorConfig[] => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

holy function nesting batman!
inline function that inlines a function into flatmap? 👀
The flatmapped one might benefit of some testing without all the other fluff man

const result = { ...actor };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

structuredClone might be safer if we ever get some nesting on the actor fields

// keep tracks of who set the key, the actor definition or the index of the config
const owners = new Map<(typeof SETTING_KEYS)[number], 'actor' | number>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smort for debuggability

for (const key of SETTING_KEYS) {
if (actor[key] !== undefined) owners.set(key, 'actor');
}

for (const [index, config] of body.configs.entries()) {
if (!matches(actor, config.match)) continue;

for (const key of SETTING_KEYS) {
if (config.set[key] === undefined) continue;

const owner = owners.get(key);
if (owner === undefined) {
owners.set(key, index);
Object.assign(result, { [key]: config.set[key] });
} else if (owner === 'actor') {
errors.push(
`Actor "${actor.actorFullName}": "${key}" is set on the actor and by configs[${index}].`,
);
} else {
errors.push(
`Actor "${actor.actorFullName}": "${key}" is set by both configs[${owner}] and configs[${index}].`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having configs be an array of rules make these errors very painful to write 😭. Maybe a shortened "selector" view could be nice.

);
}
}
}

const { tokenEnvVar } = result;
if (tokenEnvVar === undefined) {
errors.push(
`Actor "${actor.actorFullName}" has no tokenEnvVar: set it on the actor or through a matching config.`,
);
return [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some Either return could be beneficial here, with the added bonus of no mutation of error (not very functional-pilled my man 😂 )

}
return [{ ...result, tokenEnvVar }];
});

if (errors.length > 0) {
throw new Error(errors.join('\n'));
}

return resolved;
});
40 changes: 40 additions & 0 deletions bin/utils/config/structures/grouped.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import z from 'zod';

import { ACTOR_FULL_NAME_REGEX, CONFIG_FILE_STRATEGY, defineStrategy } from './base.js';

const schema = z.object({
groups: z
.record(
z.string(),
z.object({
actors: z
.array(
z.object({
folder: z.string(),
actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX),
// these allow overrides from stuff set at the group level
tokenEnvVar: z.string().optional(),
overrideActorContext: z.array(z.string()).optional(),
}),
)
.min(1),
tokenEnvVar: z.string(),
overrideActorContext: z.array(z.string()).optional(),
}),
)
.refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one configuration group is required.'),
});

type GroupedConfig = z.infer<typeof schema>;

export const GROUPED_PARSER = defineStrategy(CONFIG_FILE_STRATEGY.GROUPED, schema, (body: GroupedConfig) => {
const entries = Object.entries(body.groups);
return entries.flatMap(([_folder, entry]) =>
entry.actors.map((actor) => ({
tokenEnvVar: entry.tokenEnvVar,
overrideActorContext: entry.overrideActorContext,
// last so actor can override stuff from the group
...actor,
})),
);
});
Loading
Loading