Skip to content

Commit e50f04a

Browse files
committed
feat: add id-based deduplication for plugins
1 parent 41f3317 commit e50f04a

3 files changed

Lines changed: 179 additions & 3 deletions

File tree

packages/padrone/src/command-utils.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,24 @@ export function errorResult(error: unknown, partial?: { command?: unknown; args?
287287
});
288288
}
289289

290+
/**
291+
* Deduplicates plugins by `id`. When multiple plugins share the same `id`,
292+
* only the last one in the array is kept. Plugins without an `id` are always kept.
293+
*/
294+
function deduplicatePlugins(plugins: PadronePlugin<any, any>[]): PadronePlugin<any, any>[] {
295+
// Fast path: no ids at all
296+
if (!plugins.some((p) => p.id)) return plugins;
297+
298+
// Find the last index for each id
299+
const lastIndex = new Map<string, number>();
300+
for (let i = 0; i < plugins.length; i++) {
301+
const id = plugins[i]!.id;
302+
if (id) lastIndex.set(id, i);
303+
}
304+
305+
return plugins.filter((p, i) => !p.id || lastIndex.get(p.id) === i);
306+
}
307+
290308
/**
291309
* Runs a plugin chain for a given phase using the onion/middleware pattern.
292310
* Plugins are sorted by `order` (ascending, stable), then composed so that
@@ -299,8 +317,9 @@ export function runPluginChain<TCtx, TResult>(
299317
ctx: TCtx,
300318
core: () => TResult | Promise<TResult>,
301319
): TResult | Promise<TResult> {
302-
// Filter to plugins that have a handler for this phase, preserve insertion order
303-
const phasePlugins = plugins.filter((p) => p[phase]);
320+
// Deduplicate by id (last wins), then filter to plugins that have a handler for this phase
321+
const deduped = deduplicatePlugins(plugins);
322+
const phasePlugins = deduped.filter((p) => p[phase]);
304323
if (phasePlugins.length === 0) return core();
305324

306325
// Stable sort by order (lower = outermost). Equal order preserves registration order.

packages/padrone/src/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1203,8 +1203,14 @@ type PluginPhaseHandler<TCtx, TNextResult, TReturn = TNextResult> = (
12031203
* - Transform the return value of `next()` to alter outputs.
12041204
*/
12051205
export type PadronePlugin<TArgs = unknown, TResult = unknown> = {
1206-
/** Unique name for this plugin. Used for identification and future disable/override support. */
1206+
/** Display name for this plugin. Used for identification in logs and debugging. */
12071207
name: string;
1208+
/**
1209+
* Optional unique identifier for deduplication. When multiple plugins share the same `id`,
1210+
* only the last one registered is kept. Useful for allowing downstream code to override
1211+
* a plugin without accumulating duplicates.
1212+
*/
1213+
id?: string;
12081214
/**
12091215
* Ordering hint. Lower values run as outer layers (earlier before `next()`, later after).
12101216
* Plugins with the same order preserve registration order. Defaults to `0`.

packages/padrone/tests/plugin.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,4 +1094,155 @@ describe('plugins', () => {
10941094
expect(log).toEqual(['root', 'db', 'migrate']);
10951095
});
10961096
});
1097+
1098+
describe('id deduplication', () => {
1099+
it('should keep the last plugin when multiple share the same id', () => {
1100+
const log: string[] = [];
1101+
1102+
const program = makeProgram()
1103+
.use({
1104+
name: 'first',
1105+
id: 'auth',
1106+
execute: (_ctx, next) => {
1107+
log.push('first');
1108+
return next();
1109+
},
1110+
})
1111+
.use({
1112+
name: 'second',
1113+
id: 'auth',
1114+
execute: (_ctx, next) => {
1115+
log.push('second');
1116+
return next();
1117+
},
1118+
});
1119+
1120+
program.eval('greet World');
1121+
expect(log).toEqual(['second']);
1122+
});
1123+
1124+
it('should not affect plugins without an id', () => {
1125+
const log: string[] = [];
1126+
1127+
const program = makeProgram()
1128+
.use({
1129+
name: 'a',
1130+
execute: (_ctx, next) => {
1131+
log.push('a');
1132+
return next();
1133+
},
1134+
})
1135+
.use({
1136+
name: 'b',
1137+
execute: (_ctx, next) => {
1138+
log.push('b');
1139+
return next();
1140+
},
1141+
});
1142+
1143+
program.eval('greet World');
1144+
expect(log).toEqual(['a', 'b']);
1145+
});
1146+
1147+
it('should mix plugins with and without ids correctly', () => {
1148+
const log: string[] = [];
1149+
1150+
const program = makeProgram()
1151+
.use({
1152+
name: 'no-id-1',
1153+
execute: (_ctx, next) => {
1154+
log.push('no-id-1');
1155+
return next();
1156+
},
1157+
})
1158+
.use({
1159+
name: 'first-auth',
1160+
id: 'auth',
1161+
execute: (_ctx, next) => {
1162+
log.push('first-auth');
1163+
return next();
1164+
},
1165+
})
1166+
.use({
1167+
name: 'no-id-2',
1168+
execute: (_ctx, next) => {
1169+
log.push('no-id-2');
1170+
return next();
1171+
},
1172+
})
1173+
.use({
1174+
name: 'second-auth',
1175+
id: 'auth',
1176+
execute: (_ctx, next) => {
1177+
log.push('second-auth');
1178+
return next();
1179+
},
1180+
});
1181+
1182+
program.eval('greet World');
1183+
expect(log).toEqual(['no-id-1', 'no-id-2', 'second-auth']);
1184+
});
1185+
1186+
it('should deduplicate across parent chain (subcommand overrides parent)', () => {
1187+
const log: string[] = [];
1188+
1189+
const program = createPadrone('test')
1190+
.command('greet', (c) =>
1191+
c
1192+
.arguments(z.object({ name: z.string() }), { positional: ['name'] })
1193+
.action((args) => `Hello, ${args.name}!`)
1194+
.use({
1195+
name: 'sub-auth',
1196+
id: 'auth',
1197+
execute: (_ctx, next) => {
1198+
log.push('sub-auth');
1199+
return next();
1200+
},
1201+
}),
1202+
)
1203+
.use({
1204+
name: 'root-auth',
1205+
id: 'auth',
1206+
execute: (_ctx, next) => {
1207+
log.push('root-auth');
1208+
return next();
1209+
},
1210+
});
1211+
1212+
program.eval('greet World');
1213+
// Subcommand plugin comes after root in collected chain, so it wins
1214+
expect(log).toEqual(['sub-auth']);
1215+
});
1216+
1217+
it('should deduplicate per phase independently', () => {
1218+
const log: string[] = [];
1219+
1220+
const program = makeProgram()
1221+
.use({
1222+
name: 'first',
1223+
id: 'logger',
1224+
validate: (_ctx, next) => {
1225+
log.push('validate:first');
1226+
return next();
1227+
},
1228+
execute: (_ctx, next) => {
1229+
log.push('execute:first');
1230+
return next();
1231+
},
1232+
})
1233+
.use({
1234+
name: 'second',
1235+
id: 'logger',
1236+
execute: (_ctx, next) => {
1237+
log.push('execute:second');
1238+
return next();
1239+
},
1240+
// no validate — but dedup still removes the first plugin entirely
1241+
});
1242+
1243+
program.eval('greet World');
1244+
// The second plugin replaced the first (same id), so first's validate is gone too
1245+
expect(log).toEqual(['execute:second']);
1246+
});
1247+
});
10971248
});

0 commit comments

Comments
 (0)