Skip to content

Commit 346c385

Browse files
committed
feat(commands): add registerDeferredCommand to the command registry
Claiming a command name and loading its implementation are now separate: the registry builds routing — the command record, the parent's subcommand list and the parent dispatcher — from the name alone, and runs the loader only when that one command is resolved. A sibling's dispatch no longer drags in the first claimant's module, and the outcome comes back as a structured result instead of a thrown message callers have to match on. Names that are not lower case are rejected: dispatch lower-cases what the user typed, so they could never be reached. A loader that throws, or that leaves the command without a resolver, fails naming the owner and the source. Extract registerDefinitionAs so a definition registered under a name chosen by its registrant is built exactly like one registered under its own.
1 parent 4ca596b commit 346c385

4 files changed

Lines changed: 168 additions & 13 deletions

File tree

lib/common/contracts/command-registry.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,43 @@
11
import { Contract } from "../di/contract";
22
import type { ICommand } from "../definitions/commands";
33

4+
export interface DeferredCommandOptions {
5+
/**
6+
* Names the registrant in conflict and failure reports. Re-registering the
7+
* same command under the same owner is a no-op rather than a conflict.
8+
*/
9+
owner: string;
10+
/** Where the implementation comes from; named when loading it fails. */
11+
source: string;
12+
/**
13+
* Runs on first resolution of the command. It must leave a real resolver on
14+
* the command name — by exporting a definition the caller registers, or by
15+
* registering the command itself.
16+
*/
17+
load: () => void;
18+
}
19+
20+
/** Why a deferred registration did not take effect. */
21+
export type DeferredCommandRejection =
22+
/** The name can never be dispatched; `detail` says why. */
23+
| { reason: "invalid-name"; detail: string }
24+
/** Another owner registered the same command first. */
25+
| { reason: "claimed"; owner: string }
26+
/** The CLI itself provides the command. */
27+
| { reason: "built-in" }
28+
/** The name is in use as the dispatcher for subcommands under it. */
29+
| { reason: "subcommand-parent" };
30+
31+
/**
32+
* Outcome of a deferred registration. Callers branch on `rejection.reason`
33+
* rather than on message text, so the wording of the report stays theirs.
34+
*/
35+
export interface DeferredCommandResult {
36+
registered: boolean;
37+
/** Set exactly when `registered` is false. */
38+
rejection?: DeferredCommandRejection;
39+
}
40+
441
/**
542
* The command-registry face of the injector facade. Transitional contract: it
643
* mirrors what consumers call today, so that extracting the registry from the
@@ -10,11 +47,20 @@ import type { ICommand } from "../definitions/commands";
1047
@Contract({ name: "commandRegistry" })
1148
export abstract class CommandRegistry {
1249
/**
13-
* @deprecated Path-based command registration; slated for replacement by
14-
* manifest-declared commands.
50+
* @deprecated Path-based command registration; use registerDeferredCommand,
51+
* which routes without loading and reports conflicts structurally.
1552
*/
1653
abstract requireCommand(names: string | string[], file: string): void;
1754
abstract registerCommand(names: string | string[], resolver: any): void;
55+
/**
56+
* Claims a command name for an owner without loading anything: routing —
57+
* including the dispatcher of a hierarchical parent — is built from the name
58+
* alone, and `load` runs only when that one command is resolved.
59+
*/
60+
abstract registerDeferredCommand(
61+
name: string,
62+
options: DeferredCommandOptions,
63+
): DeferredCommandResult;
1864
abstract resolveCommand(name: string): ICommand;
1965
abstract getRegisteredCommandsNames(includeDev: boolean): string[];
2066
abstract getChildrenCommandsNames(commandName: string): string[];

lib/common/contracts/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
// physically extracted — at which point the provider is swapped and consumers
66
// keep working unchanged.
77
export { CommandRegistry } from "./command-registry";
8+
export type {
9+
DeferredCommandOptions,
10+
DeferredCommandRejection,
11+
DeferredCommandResult,
12+
} from "./command-registry";
813
export { KeyCommandRegistry } from "./key-command-registry";
914
export { ModuleRegistry } from "./module-registry";
1015
export { PublicApiBuilder } from "./public-api-builder";

lib/common/services/command-definition-adapter.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,26 @@ export function createCommandFromDefinition<
203203
};
204204
}
205205

206+
/**
207+
* Registers a definition under an externally chosen command name. Extension
208+
* manifests route by their own key, which need not be the definition's own
209+
* name, so the name is a parameter rather than read off the definition.
210+
*/
211+
export function registerDefinitionAs<TSchema extends CommandOptionsSchema>(
212+
name: string,
213+
definition: DefinedCommand<TSchema>,
214+
targetInjector: IInjector = injector,
215+
): void {
216+
// The registry facet rather than the injector itself, so a child injector
217+
// that provides its own CommandRegistry receives the registration.
218+
const registry = targetInjector.get(CommandRegistry);
219+
// A prototype-less zero-parameter function registers as a useFactory
220+
// provider, so the command is built on first resolution and cached.
221+
registry.registerCommand(name, () =>
222+
createCommandFromDefinition(definition, targetInjector),
223+
);
224+
}
225+
206226
export function registerCommandDefinition<TSchema extends CommandOptionsSchema>(
207227
definition: DefinedCommand<TSchema>,
208228
targetInjector: IInjector = injector,
@@ -214,18 +234,11 @@ export function registerCommandDefinition<TSchema extends CommandOptionsSchema>(
214234
);
215235
}
216236

217-
// The registry facet rather than the injector itself, so a child injector
218-
// that provides its own CommandRegistry receives the registration.
219-
const registry = targetInjector.get(CommandRegistry);
220237
const names = Array.isArray(definition.name)
221238
? definition.name
222239
: [definition.name];
223240

224241
for (const name of names) {
225-
// A prototype-less zero-parameter function registers as a useFactory
226-
// provider, so the command is built on first resolution and cached.
227-
registry.registerCommand(name, () =>
228-
createCommandFromDefinition(definition, targetInjector),
229-
);
242+
registerDefinitionAs(name, definition, targetInjector);
230243
}
231244
}

lib/common/yok.ts

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import {
1616
ModuleRegistry,
1717
PublicApiBuilder,
1818
} from "./contracts";
19+
import type {
20+
DeferredCommandOptions,
21+
DeferredCommandRejection,
22+
DeferredCommandResult,
23+
} from "./contracts";
1924

2025
/**
2126
* The legacy global facade binding. New code should obtain the container via
@@ -24,6 +29,10 @@ import {
2429
*/
2530
export let injector: IInjector;
2631

32+
function rejected(rejection: DeferredCommandRejection): DeferredCommandResult {
33+
return { registered: false, rejection };
34+
}
35+
2736
function forEachName(names: any, action: (name: string) => void): void {
2837
if (_.isString(names)) {
2938
action(names);
@@ -95,10 +104,12 @@ export class Yok extends Injector implements IInjector {
95104
private placeholderParents = new Set<string>();
96105
private KEY_COMMANDS_NAMESPACE: string = "keyCommands";
97106
private hierarchicalCommands: IDictionary<string[]> = {};
107+
/** Deferred command name -> the owner that claimed it first. */
108+
private deferredCommandOwners: IDictionary<string> = {};
98109

99110
/**
100-
* @deprecated Path-based command registration; slated for replacement by
101-
* manifest-declared commands.
111+
* @deprecated Path-based command registration; use registerDeferredCommand,
112+
* which routes without loading and reports conflicts structurally.
102113
*/
103114
public requireCommand(names: any, file: string): void {
104115
forEachName(names, (commandName) => {
@@ -145,6 +156,86 @@ export class Yok extends Injector implements IInjector {
145156
});
146157
}
147158

159+
public registerDeferredCommand(
160+
name: string,
161+
options: DeferredCommandOptions,
162+
): DeferredCommandResult {
163+
if (name !== name.toLowerCase()) {
164+
return rejected({
165+
reason: "invalid-name",
166+
detail:
167+
`command names are matched in lower case, so '${name}' can never ` +
168+
`be dispatched; declare it as '${name.toLowerCase()}'`,
169+
});
170+
}
171+
172+
const claimedBy = this.deferredCommandOwners[name];
173+
if (claimedBy) {
174+
return claimedBy === options.owner
175+
? { registered: true }
176+
: rejected({ reason: "claimed", owner: claimedBy });
177+
}
178+
179+
const commandRecordName = this.createCommandName(name);
180+
if (this.has(commandRecordName)) {
181+
return rejected(
182+
this.synthesizedParents.has(name)
183+
? { reason: "subcommand-parent" }
184+
: { reason: "built-in" },
185+
);
186+
}
187+
188+
super.register({
189+
provide: commandRecordName,
190+
useLazyRequire: () => {
191+
try {
192+
options.load();
193+
} catch (err) {
194+
throw new Error(
195+
`Unable to load command '${name}' of ${options.owner} from ` +
196+
`${options.source}: ${err.message}`,
197+
);
198+
}
199+
200+
if (!this.hasResolver(commandRecordName)) {
201+
throw new Error(
202+
`Command '${name}' of ${options.owner} was not registered when ` +
203+
`${options.source} loaded. The module must export a ` +
204+
`defineCommand() definition or register the command itself.`,
205+
);
206+
}
207+
},
208+
});
209+
this.deferredCommandOwners[name] = options.owner;
210+
211+
const commands = name.split(CommandsDelimiters.HierarchicalCommand);
212+
if (commands.length > 1) {
213+
const parentCommandName = commands[0];
214+
const subCommandName = _.tail(commands).join(
215+
CommandsDelimiters.HierarchicalCommand,
216+
);
217+
218+
if (!this.hierarchicalCommands[parentCommandName]) {
219+
this.hierarchicalCommands[parentCommandName] = [];
220+
}
221+
222+
if (
223+
!_.includes(
224+
this.hierarchicalCommands[parentCommandName],
225+
subCommandName,
226+
)
227+
) {
228+
this.hierarchicalCommands[parentCommandName].push(subCommandName);
229+
}
230+
231+
// The dispatcher routes off the recorded subcommand names alone, so
232+
// reaching a sibling never loads this entry's module.
233+
this.createHierarchicalCommand(parentCommandName, name);
234+
}
235+
236+
return { registered: true };
237+
}
238+
148239
/**
149240
* @deprecated Use provideLazy() from lib/common/di (via `Yok.di`) — the same
150241
* deferred loading, token-based.
@@ -227,7 +318,7 @@ export class Yok extends Injector implements IInjector {
227318
// Yok replaced the whole record on an allowed re-require, dropping any
228319
// resolver and cached instances with it — preserved via remove().
229320
this.remove(name);
230-
this.register({
321+
super.register({
231322
provide: name,
232323
useLazyRequire: () => require(dependencyPath),
233324
});

0 commit comments

Comments
 (0)