Skip to content

Commit c502143

Browse files
committed
feat(commands): provide the invocation context through an injection token
The adapter now builds a child injector per invocation providing COMMAND_CONTEXT, and runs setup, canExecute, run, postRun and shortcuts under it. Handler signatures are unchanged; the token is the way a service or a field initializer reaches the context without threading it through. The provider reads the stage's own context, and nothing outside an invocation can resolve the token.
1 parent e58a9e8 commit c502143

4 files changed

Lines changed: 122 additions & 11 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { InjectionToken } from "../di/injection-token";
2+
import type { CommandContext } from "../define-command";
3+
4+
/**
5+
* The context of the command invocation that is running. Provided by a child
6+
* injector the adapter builds per invocation, so it resolves inside `setup`,
7+
* `canExecute`, `run`, `postRun` and `shortcuts` — and nowhere else. A service
8+
* registered on the root injector never sees it.
9+
*/
10+
export const COMMAND_CONTEXT = new InjectionToken<CommandContext<any>>(
11+
"commandContext",
12+
);

lib/common/contracts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ export type {
1414
DeferredCommandRejection,
1515
DeferredCommandResult,
1616
} from "./command-registry";
17+
export { COMMAND_CONTEXT } from "./command-context";
1718
export { ModuleRegistry } from "./module-registry";
1819
export { PublicApiBuilder } from "./public-api-builder";

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

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getCurrentInjector, runInInjectionContext } from "../di/inject";
55
import { Injector } from "../di/injector";
66
import { IDictionary, IDashedOption, IErrors } from "../declarations";
77
import { ICommand } from "../definitions/commands";
8+
import { COMMAND_CONTEXT } from "../contracts/command-context";
89
import {
910
COMMAND_OWNER,
1011
CommandRegistry,
@@ -335,13 +336,17 @@ export function createCommandFromDefinition<
335336
// The state of one invocation. The command object itself is cached for the
336337
// process, so nothing invocation-scoped may live outside one of these.
337338
interface Invocation {
339+
/** The context of the stage that is running; COMMAND_CONTEXT reads it. */
340+
context: CommandContext<TSchema>;
341+
injector: Injector;
338342
setup: Promise<Awaited<TSetup>>;
339343
hasRun: boolean;
340344
runResult?: Awaited<TResult>;
341345
}
342346

343347
const startSetup = (
344348
context: CommandContext<TSchema>,
349+
injector: Injector,
345350
): Promise<Awaited<TSetup>> =>
346351
// The executor runs synchronously, so setup keeps its injection context
347352
// up to its first await, while a synchronous failure - ctx.fail() is one -
@@ -350,7 +355,7 @@ export function createCommandFromDefinition<
350355
resolve(
351356
definition.setup
352357
? <any>(
353-
runInInjectionContext(targetInjector, () =>
358+
runInInjectionContext(injector, () =>
354359
definition.setup.call(definition, context),
355360
)
356361
)
@@ -365,9 +370,25 @@ export function createCommandFromDefinition<
365370
let currentInvocation: Invocation = null;
366371

367372
const beginInvocation = (context: CommandContext<TSchema>): Invocation => {
368-
currentInvocation = { setup: startSetup(context), hasRun: false };
373+
const invocation: Invocation = {
374+
context,
375+
// Each entry point builds its own context object, so the token reads
376+
// the live one rather than a snapshot: a handler that injects it gets
377+
// the very context it was handed.
378+
injector: targetInjector.createChild([
379+
{
380+
provide: COMMAND_CONTEXT,
381+
useFactory: () => invocation.context,
382+
shared: false,
383+
},
384+
]),
385+
setup: undefined,
386+
hasRun: false,
387+
};
388+
invocation.setup = startSetup(context, invocation.injector);
389+
currentInvocation = invocation;
369390

370-
return currentInvocation;
391+
return invocation;
371392
};
372393

373394
/**
@@ -377,6 +398,7 @@ export function createCommandFromDefinition<
377398
* take the host's keys with it.
378399
*/
379400
const attachShortcuts = (
401+
invocation: Invocation,
380402
context: CommandContext<TSchema>,
381403
setupResult: Awaited<TSetup>,
382404
): void => {
@@ -392,8 +414,9 @@ export function createCommandFromDefinition<
392414
return;
393415
}
394416

395-
const shortcuts: KeyShortcut[] = runInInjectionContext(targetInjector, () =>
396-
definition.shortcuts.call(definition, context, setupResult),
417+
const shortcuts: KeyShortcut[] = runInInjectionContext(
418+
invocation.injector,
419+
() => definition.shortcuts.call(definition, context, setupResult),
397420
);
398421
if (!shortcuts || !shortcuts.length) {
399422
return;
@@ -428,8 +451,9 @@ export function createCommandFromDefinition<
428451
postCommandAction: async (args: string[]): Promise<void> => {
429452
const context = buildContext(args);
430453
const invocation = currentInvocation || beginInvocation(context);
454+
invocation.context = context;
431455
const setupResult = await invocation.setup;
432-
await runInInjectionContext(targetInjector, () =>
456+
await runInInjectionContext(invocation.injector, () =>
433457
definition.postRun.call(
434458
definition,
435459
context,
@@ -446,7 +470,8 @@ export function createCommandFromDefinition<
446470
// arguments - so an argument validator can rely on it, and a command
447471
// run in the wrong place still reports that before complaining about
448472
// arity.
449-
const setupResult = await beginInvocation(context).setup;
473+
const invocation = beginInvocation(context);
474+
const setupResult = await invocation.setup;
450475

451476
await enforceArguments(context);
452477

@@ -457,7 +482,7 @@ export function createCommandFromDefinition<
457482

458483
// Same first-await rule as execute: runInInjectionContext is
459484
// synchronous, so inject() is available up to the first await.
460-
return await runInInjectionContext(targetInjector, () =>
485+
return await runInInjectionContext(invocation.injector, () =>
461486
refine.call(definition, context, setupResult),
462487
);
463488
},
@@ -467,15 +492,17 @@ export function createCommandFromDefinition<
467492
currentInvocation && !currentInvocation.hasRun
468493
? currentInvocation
469494
: beginInvocation(context);
495+
invocation.context = context;
470496
invocation.hasRun = true;
471497

472498
const setupResult = await invocation.setup;
473-
invocation.runResult = await runInInjectionContext(targetInjector, () =>
474-
definition.run.call(definition, context, setupResult),
499+
invocation.runResult = await runInInjectionContext(
500+
invocation.injector,
501+
() => definition.run.call(definition, context, setupResult),
475502
);
476503

477504
if (definition.shortcuts) {
478-
attachShortcuts(context, setupResult);
505+
attachShortcuts(invocation, context, setupResult);
479506
}
480507
},
481508
};

test/define-command.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
InjectionToken,
99
runInInjectionContext,
1010
} from "../lib/common/di";
11+
import { COMMAND_CONTEXT } from "../lib/common/contracts/command-context";
1112
import {
1213
COMMAND_OWNER,
1314
CommandRegistry,
@@ -2275,6 +2276,76 @@ describe("defineCommand", () => {
22752276
});
22762277
});
22772278

2279+
describe("COMMAND_CONTEXT", () => {
2280+
it("resolves to the context the handlers of the same stage receive", async () => {
2281+
const testInjector = createTestInjector();
2282+
let injectedInSetup: any;
2283+
let injectedInRun: any;
2284+
let setupContext: any;
2285+
let runContext: any;
2286+
2287+
const command = createCommandFromDefinition(
2288+
defineCommand({
2289+
name: "dctest-command-context",
2290+
setup: (ctx) => {
2291+
setupContext = ctx;
2292+
injectedInSetup = inject(COMMAND_CONTEXT);
2293+
},
2294+
run: (ctx) => {
2295+
runContext = ctx;
2296+
injectedInRun = inject(COMMAND_CONTEXT);
2297+
},
2298+
}),
2299+
testInjector,
2300+
);
2301+
2302+
await command.execute([]);
2303+
2304+
assert.strictEqual(injectedInSetup, setupContext);
2305+
assert.strictEqual(injectedInRun, runContext);
2306+
});
2307+
2308+
it("is scoped to the invocation, so the root injector never sees it", async () => {
2309+
const testInjector = createTestInjector();
2310+
2311+
const command = createCommandFromDefinition(
2312+
defineCommand({
2313+
name: "dctest-command-context-scope",
2314+
run: (): void => undefined,
2315+
}),
2316+
testInjector,
2317+
);
2318+
2319+
await command.execute([]);
2320+
2321+
assert.isNull(testInjector.get(COMMAND_CONTEXT, { optional: true }));
2322+
assert.throws(
2323+
() => testInjector.get(COMMAND_CONTEXT),
2324+
/unable to resolve/,
2325+
);
2326+
});
2327+
2328+
it("gives each invocation a context of its own", async () => {
2329+
const testInjector = createTestInjector();
2330+
const seen: any[] = [];
2331+
2332+
const command = createCommandFromDefinition(
2333+
defineCommand({
2334+
name: "dctest-command-context-per-invocation",
2335+
setup: () => seen.push(inject(COMMAND_CONTEXT)),
2336+
run: (): void => undefined,
2337+
}),
2338+
testInjector,
2339+
);
2340+
2341+
await command.execute([]);
2342+
await command.execute([]);
2343+
2344+
assert.lengthOf(seen, 2);
2345+
assert.notStrictEqual(seen[0], seen[1]);
2346+
});
2347+
});
2348+
22782349
describe("per-registration parameterization with a child injector", () => {
22792350
it("registers one definition per platform and resolves the child provider", async () => {
22802351
const PLATFORM = new InjectionToken<string>("dcTestCommandPlatform");

0 commit comments

Comments
 (0)